Personal Expense Tracker in .NET

Build a Blazor expense tracker using EF Core for writes and Dapper for reads.

Introduction

30 Second Summary

Keeping track of where your money goes is one of those tasks that sounds simple but quickly gets complicated. You want to log expenses fast, but you also want meaningful summaries that help you spot spending patterns.

In this project, you will build a Personal Expense Tracker web app that lets you log expenses, categorize them, and view spending summaries on a dashboard.

What You'll Build

A full-stack expense tracker built with Blazor Web App (.NET 9) that uses EF Core for write operations and Dapper for read queries.

By the end of this project, you'll have:

  • A Blazor Web App with Interactive Server rendering that handles full CRUD operations for expenses.
  • A hybrid data access layer that uses EF Core for creates, updates, and deletes, and Dapper for fast read queries.
  • A spending dashboard powered by SQL aggregate queries that summarize your expenses by category.
  • Secret Mission: Add date-range filtering and category drill-down to the dashboard using parameterized Dapper queries.

Are there any prerequisites?

You should be familiar with C# and basic .NET concepts. You will also need Visual Studio and the .NET 9 SDK installed on your machine.

Set Up the Project

Before you can build the expense tracker, you need a project to work in. The Blazor Web App template gives you a ready-made server-rendered application with routing, layouts, and a component system already wired up.

In this step, you will create the project in Visual Studio, install the packages for database access, and confirm everything runs.

In this step, get ready to:
  • Create a new Blazor Web App project in Visual Studio.
  • Install NuGet packages for EF Core and Dapper.
  • Verify the project runs in the browser.
Create a new Blazor Web App project

Visual Studio provides a project template called Blazor Web App that scaffolds everything you need for a server-rendered application with interactive components.

  • Open Visual Studio.
  • Select Create a new project from the Start Window.
  • In the Create a new project dialog, search for and select Blazor Web App from the list of templates.
  • Click Next.
  • In the Project name field, type ExpenseTracker.
  • Choose a location for the project and click Next.
  • In the Additional information dialog, set Framework to .NET 9.0.
  • Set Interactive render mode to Server.
  • Set Interactivity location to Global.
  • Click Create.

What do these settings mean?

Interactive render mode set to Server means all UI interactions are processed on the server over a real-time SignalR connection. This is the simplest model for apps that access a database directly from components.

Interactivity location set to Global means every component in the app is interactive by default. You do not need to add @rendermode InteractiveServer to each page individually.

Visual Studio creates a project with a Components/Pages folder, a Components/Layout folder, Program.cs, and a default home page ready to go.

Install NuGet packages

Your expense tracker needs three packages: one for EF Core with SQLite, one for EF Core tooling (migrations), and one for Dapper (lightweight SQL queries).

  • Open the Package Manager Console by clicking Tools in the menu bar, then NuGet Package Manager, then Package Manager Console.
  • Install the three packages by running these commands one at a time:
Install-Package Microsoft.EntityFrameworkCore.Sqlite
Install-Package Microsoft.EntityFrameworkCore.Tools
Install-Package Dapper

What does each package do?

  • Microsoft.EntityFrameworkCore.Sqlite provides the EF Core database provider for SQLite. It also brings in Microsoft.Data.Sqlite as a transitive dependency, which Dapper will use for its connections.
  • Microsoft.EntityFrameworkCore.Tools enables the Add-Migration and Update-Database commands in the Package Manager Console.
  • Dapper is a micro-ORM that maps raw SQL query results directly to C# objects. You will use it for all read operations.
  • Wait for each command to finish before running the next. You should see "Successfully installed" messages in the console output.

Seeing a package compatibility error?

Make sure your project targets .NET 9. Right-click the project in Solution Explorer, select Properties, and confirm the Target framework is set to .NET 9.0.

If the console shows a source error, verify that nuget.org is selected as the Package source dropdown at the top of the Package Manager Console pane.

Help me fix a NuGet install error in my Blazor Web App project targeting .NET 9.

Verify the project runs

With the packages installed, confirm that the default Blazor app starts without errors.

  • Press F5 to start the project with debugging.
  • Your default browser opens and displays the Blazor sample app with a Home page and a left-side navigation menu.

What should I see?

You should see a page with the heading "Hello, world!" and a navigation sidebar with links like Home, Counter, and Weather. This confirms the project is running correctly with Interactive Server rendering enabled.

  • Stop the app by pressing Shift+F5 or clicking the stop button in the Visual Studio toolbar.

Browser doesn't open or shows an error?

Check the Output window in Visual Studio for build errors. A common issue is a port conflict. Try stopping and restarting the debugger.

If you see a certificate trust warning, click Yes to trust the ASP.NET Core development certificate.

Help me get my Blazor Web App to run in Visual Studio.

Your project is ready. Next up, you will define the Expense data model and create the SQLite database using EF Core migrations.

Define the Data Model and Create the Database

Every expense tracker needs a place to store data. Before you can log a single coffee purchase, you need to define what an expense looks like in code and give the app a database to persist it.

In this step, you will create an Expense model class, set up a database context with EF Core, and run a migration to generate your SQLite database file.

In this step, get ready to:
  • Create the Expense model with validation annotations.
  • Set up the database context and connection string.
  • Run EF Core migrations to create the SQLite database.
Create the Expense model class

The model class defines the shape of an expense record. Each property becomes a column in your database table.

  • In the Solution Explorer panel on the right, right-click the ExpenseTracker project.
  • Select Add, then New Folder.
  • Name the folder Models.
  • Right-click the new Models folder, select Add, then Class....
  • Name the file Expense.cs and click Add.
  • Replace the entire contents of the file with this code:
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ExpenseTracker.Models;

public class Expense
{
    public int Id { get; set; }

    [Required(ErrorMessage = "Description is required")]
    [StringLength(200, ErrorMessage = "Description cannot exceed 200 characters")]
    public string Description { get; set; } = string.Empty;

    [Required(ErrorMessage = "Amount is required")]
    [Range(0.01, 999999.99, ErrorMessage = "Amount must be between 0.01 and 999,999.99")]
    [Column(TypeName = "decimal(18,2)")]
    public decimal Amount { get; set; }

    [Required(ErrorMessage = "Category is required")]
    public string Category { get; set; } = string.Empty;

    [Required(ErrorMessage = "Date is required")]
    public DateTime Date { get; set; } = DateTime.Today;

    [StringLength(500, ErrorMessage = "Notes cannot exceed 500 characters")]
    public string? Notes { get; set; }
}

What does this code do?

Each property in the Expense class maps to a column in your database table. The Id property becomes the primary key automatically by EF Core convention.

The attributes like [Required] and [StringLength] are data annotations. They enforce validation rules both in the UI (Blazor forms) and at the database level.

Default values like string.Empty and DateTime.Today ensure new expenses start with sensible defaults instead of null values.

  • Save Expense.cs by pressing Ctrl+S.
  • Confirm there are no red squiggly lines in the editor. The file should compile cleanly with no errors in the Error List panel at the bottom.

Seeing red underlines?

Make sure the file is inside the Models folder. The namespace ExpenseTracker.Models must match the folder structure.

Check that you included both using statements at the top of the file.

Help me fix the red underlines in my Expense.cs model class.

Create the database context and register it

The database context is EF Core's bridge between your C# model and the actual database. It tells EF Core which tables to manage and how to connect.

  • Right-click the ExpenseTracker project in Solution Explorer.
  • Select Add, then New Folder.
  • Name the folder Data.
  • Right-click the Data folder, select Add, then Class....
  • Name the file AppDbContext.cs and click Add.
  • Replace the entire contents of the file with this code:
using Microsoft.EntityFrameworkCore;
using ExpenseTracker.Models;

namespace ExpenseTracker.Data;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
    {
    }

    // Expose the Expenses table for querying and saving
    public DbSet<Expense> Expenses => Set<Expense>();
}

What does this code do?

The AppDbContext class inherits from DbContext, which is EF Core's main class for interacting with a database. The constructor accepts configuration options (like which database to connect to) passed in by dependency injection.

The DbSet<Expense> property tells EF Core to create and manage an Expenses table in the database, mapping it to your Expense model class.

  • Save AppDbContext.cs.

Now you need to tell the app where the database lives. You will add a connection string to appsettings.json and register the context in Program.cs.

  • Open appsettings.json from Solution Explorer (it is at the project root).
  • Replace the entire contents with this updated version:
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "ConnectionStrings": {
    "DefaultConnection": "Data Source=expenses.db"
  },
  "AllowedHosts": "*"
}

What is this connection string?

The connection string Data Source=expenses.db tells SQLite to create a database file called expenses.db in your project directory. SQLite stores everything in a single portable file. No database server needed.

  • Save appsettings.json.
  • Open Program.cs from Solution Explorer.
  • At the top of the file, add these two using statements above the existing ones:
using Microsoft.EntityFrameworkCore;
using ExpenseTracker.Data;
  • Find the line that says builder.Services.AddRazorComponents().
  • Directly below the .AddInteractiveServerComponents(); line, add this registration:
builder.Services.AddDbContextFactory<AppDbContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));

Why AddDbContextFactory?

Blazor Interactive Server uses long-lived connections (SignalR circuits). A regular AddDbContext registration ties a DbContext to the circuit lifetime, which causes issues with concurrent operations.

Using AddDbContextFactory lets you create short-lived DbContext instances on demand. Each database operation gets its own context, avoiding lifetime conflicts.

  • Save Program.cs.
  • Confirm there are no red squiggly lines. The Error List panel should show zero errors.

Seeing an error about AppDbContext or UseSqlite?

Make sure you added both using Microsoft.EntityFrameworkCore; and using ExpenseTracker.Data; at the top of Program.cs.

If UseSqlite is not recognized, confirm the Microsoft.EntityFrameworkCore.Sqlite NuGet package was installed in Step 1.

Help me fix the DbContext registration error in Program.cs.

Run EF Core migrations to create the database

EF Core migrations translate your model class into actual database tables. You run two commands: one to generate the migration code, and one to apply it.

  • Open the Package Manager Console by clicking Tools in the top menu bar.
  • Select NuGet Package Manager, then Package Manager Console.
  • Generate the migration by running this command in the Package Manager Console:
Add-Migration InitialCreate

What does this command do?

This command scans your AppDbContext and model classes, then generates C# code that describes how to create the database schema. You will see a new Migrations folder appear in Solution Explorer with timestamped migration files.

  • Confirm you see a Migrations folder in Solution Explorer containing files like InitialCreate.cs.
  • Apply the migration to create the database file by running:
Update-Database
  • Confirm the command completes with a Done. message in the Package Manager Console output.
  • In Solution Explorer, click the Show All Files button at the top of the panel. You should see expenses.db appear in the project root.

What just happened?

The Update-Database command applied your migration to create an actual SQLite database file called expenses.db. This file now contains an Expenses table with columns matching your model properties: Id, Description, Amount, Category, Date, and Notes.

Migration command failed?

If you see a build error, make sure there are no compile errors in your project first. Check the Error List panel and fix any issues before re-running the migration.

If the console says the Add-Migration command is not recognized, confirm that Microsoft.EntityFrameworkCore.Tools was installed as a NuGet package in Step 1.

Help me fix my EF Core migration error.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is the full code for each file at this point:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ExpenseTracker.Models;

public class Expense
{
    public int Id { get; set; }

    [Required(ErrorMessage = "Description is required")]
    [StringLength(200, ErrorMessage = "Description cannot exceed 200 characters")]
    public string Description { get; set; } = string.Empty;

    [Required(ErrorMessage = "Amount is required")]
    [Range(0.01, 999999.99, ErrorMessage = "Amount must be between 0.01 and 999,999.99")]
    [Column(TypeName = "decimal(18,2)")]
    public decimal Amount { get; set; }

    [Required(ErrorMessage = "Category is required")]
    public string Category { get; set; } = string.Empty;

    [Required(ErrorMessage = "Date is required")]
    public DateTime Date { get; set; } = DateTime.Today;

    [StringLength(500, ErrorMessage = "Notes cannot exceed 500 characters")]
    public string? Notes { get; set; }
}
using Microsoft.EntityFrameworkCore;
using ExpenseTracker.Models;

namespace ExpenseTracker.Data;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
    {
    }

    public DbSet<Expense> Expenses => Set<Expense>();
}
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "ConnectionStrings": {
    "DefaultConnection": "Data Source=expenses.db"
  },
  "AllowedHosts": "*"
}
using Microsoft.EntityFrameworkCore;
using ExpenseTracker.Data;
using ExpenseTracker.Components;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

builder.Services.AddDbContextFactory<AppDbContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

app.Run();

Your database is ready and waiting for data. Next up, you will build the service layer and expense list page so you can actually create, view, and delete expenses.

Build the Expense List and Delete Feature

Your database is ready and waiting for data. Now you need a way for users to actually see their expenses and remove ones they no longer need.

In this step, you'll build a service layer that sits between your pages and the database. The service uses Dapper for reading expenses (fast, raw SQL) and EF Core for deleting them (change tracking and safe removal).

In this step, get ready to:
  • Create a service interface and implementation with hybrid Dapper/EF Core data access.
  • Register the service and build an expense list page with a Bootstrap table.
  • Add a delete confirmation modal that removes expenses from the database.
Create the service interface and implementation

A service layer keeps your database logic separate from your UI pages. You'll define a contract (the interface) and then implement it with the hybrid pattern.

  • In Solution Explorer, right-click the ExpenseTracker project.
  • Select Add, then New Folder.
  • Name the folder Services and press Enter.
  • Right-click the new Services folder, select Add then New Item.
  • Choose Interface, name it IExpenseService.cs, and click Add.
  • Replace the file contents with the following code:
using ExpenseTracker.Models;

namespace ExpenseTracker.Services;

public interface IExpenseService
{
    Task<IEnumerable<Expense>> GetAllAsync();
    Task<Expense?> GetByIdAsync(int id);
    Task CreateAsync(Expense expense);
    Task UpdateAsync(Expense expense);
    Task DeleteAsync(int id);
    Task<decimal> GetMonthlyTotalAsync(int year, int month);
    Task<IEnumerable<CategorySpending>> GetSpendingByCategoryAsync(int year, int month);
}

// Holds category-level spending totals for the dashboard
public class CategorySpending
{
    public string Category { get; set; } = string.Empty;
    public decimal Total { get; set; }
    public decimal Percentage { get; set; }
}

What does this code do?

This interface defines the full contract for expense data access. It includes methods for CRUD operations (GetAllAsync, CreateAsync, UpdateAsync, DeleteAsync) and dashboard queries (GetMonthlyTotalAsync, GetSpendingByCategoryAsync).

The CategorySpending class is a simple data container that holds the result of aggregate SQL queries you'll write later.

  • Save IExpenseService.cs.

Seeing red squiggles under Expense?

Make sure the using ExpenseTracker.Models; line is at the top of the file. Without it, the compiler cannot find the Expense class. Help me fix the namespace issue in my service interface.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

using ExpenseTracker.Models;

namespace ExpenseTracker.Services;

public interface IExpenseService
{
    Task<IEnumerable<Expense>> GetAllAsync();
    Task<Expense?> GetByIdAsync(int id);
    Task CreateAsync(Expense expense);
    Task UpdateAsync(Expense expense);
    Task DeleteAsync(int id);
    Task<decimal> GetMonthlyTotalAsync(int year, int month);
    Task<IEnumerable<CategorySpending>> GetSpendingByCategoryAsync(int year, int month);
}

public class CategorySpending
{
    public string Category { get; set; } = string.Empty;
    public decimal Total { get; set; }
    public decimal Percentage { get; set; }
}
  • Right-click the Services folder again, select Add then Class.
  • Name it ExpenseService.cs and click Add.
  • Replace the file contents with the following code:
using Dapper;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using ExpenseTracker.Data;
using ExpenseTracker.Models;

namespace ExpenseTracker.Services;

public class ExpenseService : IExpenseService
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;
    private readonly string _connectionString;

    public ExpenseService(IDbContextFactory<AppDbContext> contextFactory, IConfiguration configuration)
    {
        _contextFactory = contextFactory;
        _connectionString = configuration.GetConnectionString("DefaultConnection")!;
    }

    // READ operations use Dapper for performance and SQL flexibility
    public async Task<IEnumerable<Expense>> GetAllAsync()
    {
        using var connection = new SqliteConnection(_connectionString);
        return await connection.QueryAsync<Expense>(
            "SELECT Id, Description, Amount, Category, Date, Notes FROM Expenses ORDER BY Date DESC");
    }

    public async Task<Expense?> GetByIdAsync(int id)
    {
        using var connection = new SqliteConnection(_connectionString);
        return await connection.QueryFirstOrDefaultAsync<Expense>(
            "SELECT Id, Description, Amount, Category, Date, Notes FROM Expenses WHERE Id = @Id",
            new { Id = id });
    }

    public async Task<decimal> GetMonthlyTotalAsync(int year, int month)
    {
        throw new NotImplementedException();
    }

    public async Task<IEnumerable<CategorySpending>> GetSpendingByCategoryAsync(int year, int month)
    {
        throw new NotImplementedException();
    }

    public async Task CreateAsync(Expense expense)
    {
        throw new NotImplementedException();
    }

    public async Task UpdateAsync(Expense expense)
    {
        throw new NotImplementedException();
    }

    // WRITE operations use EF Core for change tracking and validation
    public async Task DeleteAsync(int id)
    {
        using var context = await _contextFactory.CreateDbContextAsync();
        var expense = await context.Expenses.FindAsync(id);
        if (expense is not null)
        {
            context.Expenses.Remove(expense);
            await context.SaveChangesAsync();
        }
    }
}

What does this code do?

This is where the hybrid pattern lives. The constructor takes both an IDbContextFactory (for EF Core operations) and IConfiguration (to get the raw connection string for Dapper).

The GetAllAsync method opens a lightweight SqliteConnection and uses Dapper's QueryAsync to run a raw SQL SELECT. No DbContext overhead, no change tracking. Pure speed for reads.

The DeleteAsync method creates a short-lived DbContext, finds the expense by ID, removes it, and saves. EF Core handles the SQL DELETE statement and ensures data integrity.

The remaining methods throw NotImplementedException for now. You'll implement them in the next steps.

  • Save ExpenseService.cs.
  • Confirm the project builds without errors by pressing Ctrl+Shift+B.

Build errors mentioning Dapper or SqliteConnection?

Make sure you installed the Dapper and Microsoft.EntityFrameworkCore.Sqlite NuGet packages in Step 1. Without them, the using Dapper; and using Microsoft.Data.Sqlite; lines will fail. Help me resolve missing NuGet package errors in my ExpenseService.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file and the build succeeds.

ⓧ I'd like to double check the full code

using Dapper;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using ExpenseTracker.Data;
using ExpenseTracker.Models;

namespace ExpenseTracker.Services;

public class ExpenseService : IExpenseService
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;
    private readonly string _connectionString;

    public ExpenseService(IDbContextFactory<AppDbContext> contextFactory, IConfiguration configuration)
    {
        _contextFactory = contextFactory;
        _connectionString = configuration.GetConnectionString("DefaultConnection")!;
    }

    // READ operations use Dapper for performance and SQL flexibility
    public async Task<IEnumerable<Expense>> GetAllAsync()
    {
        using var connection = new SqliteConnection(_connectionString);
        return await connection.QueryAsync<Expense>(
            "SELECT Id, Description, Amount, Category, Date, Notes FROM Expenses ORDER BY Date DESC");
    }

    public async Task<Expense?> GetByIdAsync(int id)
    {
        using var connection = new SqliteConnection(_connectionString);
        return await connection.QueryFirstOrDefaultAsync<Expense>(
            "SELECT Id, Description, Amount, Category, Date, Notes FROM Expenses WHERE Id = @Id",
            new { Id = id });
    }

    public async Task<decimal> GetMonthlyTotalAsync(int year, int month)
    {
        throw new NotImplementedException();
    }

    public async Task<IEnumerable<CategorySpending>> GetSpendingByCategoryAsync(int year, int month)
    {
        throw new NotImplementedException();
    }

    public async Task CreateAsync(Expense expense)
    {
        throw new NotImplementedException();
    }

    public async Task UpdateAsync(Expense expense)
    {
        throw new NotImplementedException();
    }

    // WRITE operations use EF Core for change tracking and validation
    public async Task DeleteAsync(int id)
    {
        using var context = await _contextFactory.CreateDbContextAsync();
        var expense = await context.Expenses.FindAsync(id);
        if (expense is not null)
        {
            context.Expenses.Remove(expense);
            await context.SaveChangesAsync();
        }
    }
}

Why EF Core for writes and Dapper for reads?

EF Core shines when you need change tracking, validation, and migrations. When you call Remove and SaveChangesAsync, EF Core generates the correct DELETE statement and handles concurrency for you.

Dapper shines when you just need to read data fast. It maps SQL results directly to objects with zero overhead from change tracking. For list pages and dashboards, this difference matters at scale.

Production .NET teams commonly split responsibilities this way. EF Core owns the write path. Dapper owns the read path.

Register the service and build the expense list page
  • In Solution Explorer, open Program.cs.
  • Find this line:
builder.Services.AddDbContextFactory<AppDbContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));
  • Directly below it, add this line to register your service:
builder.Services.AddScoped<IExpenseService, ExpenseService>();

What does AddScoped do?

This tells the dependency injection container to create one ExpenseService instance per request scope. Any page that injects IExpenseService will receive this implementation automatically.

  • Save Program.cs.

Now you'll create the Razor page that displays expenses in a table.

  • In Solution Explorer, expand the Components folder, then the Pages folder.
  • Right-click the Pages folder, select Add, then New Folder. Name it Expenses.
  • Right-click the new Expenses folder, select Add, then Razor Component.
  • Name it Index.razor and click Add.
  • Replace the file contents with the following code:
@page "/expenses"
@using ExpenseTracker.Models
@using ExpenseTracker.Services
@inject IExpenseService ExpenseService
@inject NavigationManager Navigation
@rendermode InteractiveServer

<PageTitle>Expenses</PageTitle>

<div class="d-flex justify-content-between align-items-center mb-4">
    <h3>Expenses</h3>
    <a href="/expenses/create" class="btn btn-primary">Add Expense</a>
</div>

@if (expenses.Any())
{
    <table class="table table-striped table-hover">
        <thead>
            <tr>
                <th>Date</th>
                <th>Description</th>
                <th>Category</th>
                <th class="text-end">Amount</th>
                <th>Actions</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var expense in expenses)
            {
                <tr>
                    <td>@expense.Date.ToString("MMM dd, yyyy")</td>
                    <td>@expense.Description</td>
                    <td><span class="badge bg-secondary">@expense.Category</span></td>
                    <td class="text-end">@expense.Amount.ToString("C")</td>
                    <td>
                        <a href="/expenses/edit/@expense.Id" class="btn btn-sm btn-outline-primary me-1">Edit</a>
                        <button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(expense)">Delete</button>
                    </td>
                </tr>
            }
        </tbody>
    </table>
}
else
{
    <div class="alert alert-info">No expenses yet. <a href="/expenses/create">Add your first expense!</a></div>
}

@code {
    private IEnumerable<Expense> expenses = Enumerable.Empty<Expense>();
    private Expense? expenseToDelete;

    protected override async Task OnInitializedAsync()
    {
        await LoadExpenses();
    }

    private async Task LoadExpenses()
    {
        expenses = await ExpenseService.GetAllAsync();
    }

    private void ConfirmDelete(Expense expense)
    {
        expenseToDelete = expense;
    }

    private void CancelDelete()
    {
        expenseToDelete = null;
    }

    private async Task DeleteExpense()
    {
        if (expenseToDelete is not null)
        {
            await ExpenseService.DeleteAsync(expenseToDelete.Id);
            expenseToDelete = null;
            await LoadExpenses();
        }
    }
}

What does this page do?

The @page "/expenses" directive maps this component to the /expenses URL. The @inject lines pull in the expense service and navigation manager via dependency injection.

The table iterates over expenses and displays each one with formatted date, description, a category badge, and currency-formatted amount. If no expenses exist, it shows an empty-state alert.

The @code block loads expenses when the page initializes, and includes methods for the delete confirmation flow you'll wire up next.

  • Save Index.razor.
  • Press F5 to run the app, then navigate to /expenses in your browser.
  • You should see the empty-state message: "No expenses yet. Add your first expense!"

Page showing a blank screen or 404?

Check that Index.razor is inside Components/Pages/Expenses/ (not directly in Components/Pages/). Also confirm the @page "/expenses" directive is the very first line of the file. Help me figure out why my expenses page isn't loading.

Add a delete confirmation modal

The @code block already has the delete logic. Now you need the modal UI that appears when a user clicks the Delete button, asking them to confirm before the expense is actually removed.

  • In Index.razor, find the closing } of the else block (the line right after the empty-state div).
  • Add the following code between that closing } and the @code block:
@if (expenseToDelete is not null)
{
    <div class="modal d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">Confirm Delete</h5>
                </div>
                <div class="modal-body">
                    <p>Are you sure you want to delete "@expenseToDelete.Description" (@expenseToDelete.Amount.ToString("C"))?</p>
                </div>
                <div class="modal-footer">
                    <button class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
                    <button class="btn btn-danger" @onclick="DeleteExpense">Delete</button>
                </div>
            </div>
        </div>
    </div>
}

How does the modal flow work?

When a user clicks Delete on a table row, ConfirmDelete sets expenseToDelete to that expense. Blazor re-renders the page, and since expenseToDelete is no longer null, the modal HTML appears.

If the user clicks Cancel, the variable resets to null and the modal disappears. If they click Delete, the service removes the expense from the database, resets the variable, and reloads the list.

  • Save Index.razor.
  • Refresh the /expenses page in your browser. You should still see the empty-state message.

The delete modal will appear once you have expenses to delete. You'll build the create form in the next step.

Seeing a render error in the browser?

Check that the modal block is placed between the else block's closing } and the @code block. Razor syntax is sensitive to placement. Also confirm you have matching opening and closing div tags. Help me fix a render error on my expenses page.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file and the page loads without errors.

ⓧ I'd like to double check the full code

@page "/expenses"
@using ExpenseTracker.Models
@using ExpenseTracker.Services
@inject IExpenseService ExpenseService
@inject NavigationManager Navigation
@rendermode InteractiveServer

<PageTitle>Expenses</PageTitle>

<div class="d-flex justify-content-between align-items-center mb-4">
    <h3>Expenses</h3>
    <a href="/expenses/create" class="btn btn-primary">Add Expense</a>
</div>

@if (expenses.Any())
{
    <table class="table table-striped table-hover">
        <thead>
            <tr>
                <th>Date</th>
                <th>Description</th>
                <th>Category</th>
                <th class="text-end">Amount</th>
                <th>Actions</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var expense in expenses)
            {
                <tr>
                    <td>@expense.Date.ToString("MMM dd, yyyy")</td>
                    <td>@expense.Description</td>
                    <td><span class="badge bg-secondary">@expense.Category</span></td>
                    <td class="text-end">@expense.Amount.ToString("C")</td>
                    <td>
                        <a href="/expenses/edit/@expense.Id" class="btn btn-sm btn-outline-primary me-1">Edit</a>
                        <button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(expense)">Delete</button>
                    </td>
                </tr>
            }
        </tbody>
    </table>
}
else
{
    <div class="alert alert-info">No expenses yet. <a href="/expenses/create">Add your first expense!</a></div>
}

@if (expenseToDelete is not null)
{
    <div class="modal d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">Confirm Delete</h5>
                </div>
                <div class="modal-body">
                    <p>Are you sure you want to delete "@expenseToDelete.Description" (@expenseToDelete.Amount.ToString("C"))?</p>
                </div>
                <div class="modal-footer">
                    <button class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
                    <button class="btn btn-danger" @onclick="DeleteExpense">Delete</button>
                </div>
            </div>
        </div>
    </div>
}

@code {
    private IEnumerable<Expense> expenses = Enumerable.Empty<Expense>();
    private Expense? expenseToDelete;

    protected override async Task OnInitializedAsync()
    {
        await LoadExpenses();
    }

    private async Task LoadExpenses()
    {
        expenses = await ExpenseService.GetAllAsync();
    }

    private void ConfirmDelete(Expense expense)
    {
        expenseToDelete = expense;
    }

    private void CancelDelete()
    {
        expenseToDelete = null;
    }

    private async Task DeleteExpense()
    {
        if (expenseToDelete is not null)
        {
            await ExpenseService.DeleteAsync(expenseToDelete.Id);
            expenseToDelete = null;
            await LoadExpenses();
        }
    }
}

Your expense list page is live and the service layer is ready to handle both reads and deletes. Next up, you'll build the create and edit forms so you can add expenses to the list.

Build Create and Edit Forms with EF Core

In the previous step, you built the expense list page and wired up deletion. Your users can see and remove expenses, but they have no way to add or edit them yet.

In this step, you will implement the write side of your service layer using EF Core and build two Blazor form pages. EF Core's change tracking makes it the ideal tool for Create and Update operations because it handles INSERT and UPDATE SQL generation automatically.

In this step, get ready to:
  • Implement CreateAsync and UpdateAsync methods in the service with EF Core.
  • Build a Create page with form validation and a category dropdown.
  • Build an Edit page that loads an expense by ID and saves changes.
Implement CreateAsync and UpdateAsync in the service

Your service currently has placeholder stubs that throw NotImplementedException for the write methods. You need to replace these with real EF Core logic that persists data to your SQLite database.

  • In Services/ExpenseService.cs, find the CreateAsync method that currently throws NotImplementedException.
  • Replace the entire CreateAsync method body with this implementation:
public async Task CreateAsync(Expense expense)
{
    // Create a short-lived DbContext for this write operation
    using var context = await _contextFactory.CreateDbContextAsync();
    // Add the expense entity to EF Core's change tracker
    context.Expenses.Add(expense);
    // Persist the tracked changes to the database
    await context.SaveChangesAsync();
}

What does this code do?

EF Core's Add method marks the entity as "new" in the change tracker. When SaveChangesAsync runs, EF Core generates an INSERT statement and sends it to SQLite.

The CreateDbContextAsync factory pattern creates a fresh context for each operation. This is the recommended approach for Blazor Server apps where long-lived contexts cause concurrency issues.

  • Now find the UpdateAsync method directly below and replace its body with this implementation:
public async Task UpdateAsync(Expense expense)
{
    // Create a short-lived DbContext for this write operation
    using var context = await _contextFactory.CreateDbContextAsync();
    // Attach the modified entity and mark all properties as changed
    context.Expenses.Update(expense);
    // Persist the tracked changes to the database
    await context.SaveChangesAsync();
}

What is the difference between Add and Update?

The Update method tells EF Core that this entity already exists in the database but has been modified. EF Core generates an UPDATE statement instead of an INSERT.

Because the expense object comes from a different context (it was loaded by Dapper, not this EF Core context), you use Update to attach it and mark all its properties as modified in one call.

  • Save Services/ExpenseService.cs.

Seeing red squiggles?

Make sure you removed the entire throw new NotImplementedException(); line from both methods. The method signature (public async Task) should remain unchanged.

help me fix compiler errors in my ExpenseService

Create the Add Expense form page

With the write methods in place, you can now build a form that lets users enter expense details. Blazor's EditForm component provides built-in two-way binding and validation support.

  • In the Components/Pages/Expenses folder (the same folder that holds Index.razor), create a new file called Create.razor.
  • Add the following code to Create.razor:
@page "/expenses/create"
@using ExpenseTracker.Models
@using ExpenseTracker.Services
@inject IExpenseService ExpenseService
@inject NavigationManager Navigation
@rendermode InteractiveServer

<PageTitle>Add Expense</PageTitle>

<h3 class="mb-4">Add Expense</h3>

<div class="row">
    <div class="col-md-6">
        <EditForm Model="expense" OnValidSubmit="HandleSubmit" FormName="CreateExpense">
            <DataAnnotationsValidator />
            <ValidationSummary class="text-danger" />

            <div class="mb-3">
                <label for="description" class="form-label">Description</label>
                <InputText id="description" class="form-control" @bind-Value="expense.Description" />
                <ValidationMessage For="() => expense.Description" class="text-danger" />
            </div>

            <div class="mb-3">
                <label for="amount" class="form-label">Amount</label>
                <InputNumber id="amount" class="form-control" @bind-Value="expense.Amount" />
                <ValidationMessage For="() => expense.Amount" class="text-danger" />
            </div>

            <div class="mb-3">
                <label for="category" class="form-label">Category</label>
                <InputSelect id="category" class="form-select" @bind-Value="expense.Category">
                    <option value="">-- Select Category --</option>
                    <option value="Food">Food</option>
                    <option value="Transport">Transport</option>
                    <option value="Housing">Housing</option>
                    <option value="Entertainment">Entertainment</option>
                    <option value="Utilities">Utilities</option>
                    <option value="Healthcare">Healthcare</option>
                    <option value="Other">Other</option>
                </InputSelect>
                <ValidationMessage For="() => expense.Category" class="text-danger" />
            </div>

            <div class="mb-3">
                <label for="date" class="form-label">Date</label>
                <InputDate id="date" class="form-control" @bind-Value="expense.Date" />
                <ValidationMessage For="() => expense.Date" class="text-danger" />
            </div>

            <div class="mb-3">
                <label for="notes" class="form-label">Notes (optional)</label>
                <InputTextArea id="notes" class="form-control" @bind-Value="expense.Notes" rows="3" />
                <ValidationMessage For="() => expense.Notes" class="text-danger" />
            </div>

            <div class="d-flex gap-2">
                <button type="submit" class="btn btn-primary">Save Expense</button>
                <a href="/expenses" class="btn btn-secondary">Cancel</a>
            </div>
        </EditForm>
    </div>
</div>

@code {
    private Expense expense = new() { Date = DateTime.Today };

    private async Task HandleSubmit()
    {
        await ExpenseService.CreateAsync(expense);
        Navigation.NavigateTo("/expenses");
    }
}

What does this page do?

  • EditForm binds to the expense object and calls HandleSubmit only when all validation rules pass.
  • DataAnnotationsValidator reads the [Required] and [Range] attributes you added to the Expense model and enforces them before submission.
  • InputSelect renders a dropdown bound to expense.Category with seven predefined options.
  • After a successful save, NavigateTo redirects the user back to the expense list.
  • Save Create.razor.
  • Press F5 to run the app, then navigate to /expenses/create in your browser.
  • You should see a form with fields for Description, Amount, Category (dropdown), Date, and Notes.
  • Try clicking Save Expense with empty fields. You should see red validation messages appear.

Form not responding to clicks?

Make sure the @rendermode InteractiveServer directive is at the top of Create.razor. Without it, the page renders statically and event handlers won't fire.

Also confirm the FormName="CreateExpense" attribute is present on the EditForm. This is required for form handling in .NET 9 Blazor.

help me debug my Blazor form

Create the Edit Expense form page

The Edit page is similar to Create, but it must load an existing expense from the database before displaying the form. Your service's GetByIdAsync method (which uses Dapper) handles the read, and UpdateAsync (which uses EF Core) handles the save.

  • In the Components/Pages/Expenses folder, create a new file called Edit.razor.
  • Add the following code to Edit.razor:
@page "/expenses/edit/{Id:int}"
@using ExpenseTracker.Models
@using ExpenseTracker.Services
@inject IExpenseService ExpenseService
@inject NavigationManager Navigation
@rendermode InteractiveServer

<PageTitle>Edit Expense</PageTitle>

<h3 class="mb-4">Edit Expense</h3>

@if (expense is null)
{
    <div class="alert alert-warning">Expense not found.</div>
}
else
{
    <div class="row">
        <div class="col-md-6">
            <EditForm Model="expense" OnValidSubmit="HandleSubmit" FormName="EditExpense">
                <DataAnnotationsValidator />
                <ValidationSummary class="text-danger" />

                <div class="mb-3">
                    <label for="description" class="form-label">Description</label>
                    <InputText id="description" class="form-control" @bind-Value="expense.Description" />
                    <ValidationMessage For="() => expense.Description" class="text-danger" />
                </div>

                <div class="mb-3">
                    <label for="amount" class="form-label">Amount</label>
                    <InputNumber id="amount" class="form-control" @bind-Value="expense.Amount" />
                    <ValidationMessage For="() => expense.Amount" class="text-danger" />
                </div>

                <div class="mb-3">
                    <label for="category" class="form-label">Category</label>
                    <InputSelect id="category" class="form-select" @bind-Value="expense.Category">
                        <option value="">-- Select Category --</option>
                        <option value="Food">Food</option>
                        <option value="Transport">Transport</option>
                        <option value="Housing">Housing</option>
                        <option value="Entertainment">Entertainment</option>
                        <option value="Utilities">Utilities</option>
                        <option value="Healthcare">Healthcare</option>
                        <option value="Other">Other</option>
                    </InputSelect>
                    <ValidationMessage For="() => expense.Category" class="text-danger" />
                </div>

                <div class="mb-3">
                    <label for="date" class="form-label">Date</label>
                    <InputDate id="date" class="form-control" @bind-Value="expense.Date" />
                    <ValidationMessage For="() => expense.Date" class="text-danger" />
                </div>

                <div class="mb-3">
                    <label for="notes" class="form-label">Notes (optional)</label>
                    <InputTextArea id="notes" class="form-control" @bind-Value="expense.Notes" rows="3" />
                    <ValidationMessage For="() => expense.Notes" class="text-danger" />
                </div>

                <div class="d-flex gap-2">
                    <button type="submit" class="btn btn-primary">Update Expense</button>
                    <a href="/expenses" class="btn btn-secondary">Cancel</a>
                </div>
            </EditForm>
        </div>
    </div>
}

@code {
    [Parameter]
    public int Id { get; set; }

    private Expense? expense;

    protected override async Task OnInitializedAsync()
    {
        expense = await ExpenseService.GetByIdAsync(Id);
    }

    private async Task HandleSubmit()
    {
        if (expense is not null)
        {
            await ExpenseService.UpdateAsync(expense);
            Navigation.NavigateTo("/expenses");
        }
    }
}

What makes Edit different from Create?

  • The route @page "/expenses/edit/{Id:int}" captures the expense ID from the URL and maps it to the [Parameter] property.
  • OnInitializedAsync calls GetByIdAsync (a Dapper query) to load the existing expense before rendering the form.
  • The null check around the form shows a warning message if the ID does not match any record in the database.
  • This is the hybrid pattern in action: Dapper reads the record, the user edits it in the form, and EF Core writes the changes back.
  • Save Edit.razor.
  • Run the app with F5.
  • Navigate to /expenses/create and add a test expense (for example: Description Lunch, Amount 12.50, Category Food).
  • After saving, you should be redirected to the expense list where your new expense appears.
  • Click the Edit button next to your expense. The Edit form should load with the existing values pre-filled.
  • Change the amount, then click Update Expense. You should see the updated value on the expense list.

Edit page shows Expense not found?

Check that the URL in your browser matches the pattern /expenses/edit/1 (with an actual numeric ID). If you navigated manually, make sure the ID corresponds to a record that exists in the database.

If the list page shows expenses but Edit always says not found, verify that GetByIdAsync in your service uses WHERE Id = @Id with the correct parameter name.

help me debug my Edit page

✔️ Awesome, I've got everything!

Great. Make sure you have saved all three files: ExpenseService.cs, Create.razor, and Edit.razor.

ⓧ I'd like to double check the full code

Here is the complete ExpenseService.cs after this step:

using Dapper;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using ExpenseTracker.Data;
using ExpenseTracker.Models;

namespace ExpenseTracker.Services;

public class ExpenseService : IExpenseService
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;
    private readonly string _connectionString;

    public ExpenseService(IDbContextFactory<AppDbContext> contextFactory, IConfiguration configuration)
    {
        _contextFactory = contextFactory;
        _connectionString = configuration.GetConnectionString("DefaultConnection")!;
    }

    public async Task<IEnumerable<Expense>> GetAllAsync()
    {
        using var connection = new SqliteConnection(_connectionString);
        return await connection.QueryAsync<Expense>(
            "SELECT Id, Description, Amount, Category, Date, Notes FROM Expenses ORDER BY Date DESC");
    }

    public async Task<Expense?> GetByIdAsync(int id)
    {
        using var connection = new SqliteConnection(_connectionString);
        return await connection.QueryFirstOrDefaultAsync<Expense>(
            "SELECT Id, Description, Amount, Category, Date, Notes FROM Expenses WHERE Id = @Id",
            new { Id = id });
    }

    public async Task<decimal> GetMonthlyTotalAsync(int year, int month)
    {
        throw new NotImplementedException();
    }

    public async Task<IEnumerable<CategorySpending>> GetSpendingByCategoryAsync(int year, int month)
    {
        throw new NotImplementedException();
    }

    public async Task CreateAsync(Expense expense)
    {
        using var context = await _contextFactory.CreateDbContextAsync();
        context.Expenses.Add(expense);
        await context.SaveChangesAsync();
    }

    public async Task UpdateAsync(Expense expense)
    {
        using var context = await _contextFactory.CreateDbContextAsync();
        context.Expenses.Update(expense);
        await context.SaveChangesAsync();
    }

    public async Task DeleteAsync(int id)
    {
        using var context = await _contextFactory.CreateDbContextAsync();
        var expense = await context.Expenses.FindAsync(id);
        if (expense is not null)
        {
            context.Expenses.Remove(expense);
            await context.SaveChangesAsync();
        }
    }
}

Here is the complete Create.razor:

@page "/expenses/create"
@using ExpenseTracker.Models
@using ExpenseTracker.Services
@inject IExpenseService ExpenseService
@inject NavigationManager Navigation
@rendermode InteractiveServer

<PageTitle>Add Expense</PageTitle>

<h3 class="mb-4">Add Expense</h3>

<div class="row">
    <div class="col-md-6">
        <EditForm Model="expense" OnValidSubmit="HandleSubmit" FormName="CreateExpense">
            <DataAnnotationsValidator />
            <ValidationSummary class="text-danger" />

            <div class="mb-3">
                <label for="description" class="form-label">Description</label>
                <InputText id="description" class="form-control" @bind-Value="expense.Description" />
                <ValidationMessage For="() => expense.Description" class="text-danger" />
            </div>

            <div class="mb-3">
                <label for="amount" class="form-label">Amount</label>
                <InputNumber id="amount" class="form-control" @bind-Value="expense.Amount" />
                <ValidationMessage For="() => expense.Amount" class="text-danger" />
            </div>

            <div class="mb-3">
                <label for="category" class="form-label">Category</label>
                <InputSelect id="category" class="form-select" @bind-Value="expense.Category">
                    <option value="">-- Select Category --</option>
                    <option value="Food">Food</option>
                    <option value="Transport">Transport</option>
                    <option value="Housing">Housing</option>
                    <option value="Entertainment">Entertainment</option>
                    <option value="Utilities">Utilities</option>
                    <option value="Healthcare">Healthcare</option>
                    <option value="Other">Other</option>
                </InputSelect>
                <ValidationMessage For="() => expense.Category" class="text-danger" />
            </div>

            <div class="mb-3">
                <label for="date" class="form-label">Date</label>
                <InputDate id="date" class="form-control" @bind-Value="expense.Date" />
                <ValidationMessage For="() => expense.Date" class="text-danger" />
            </div>

            <div class="mb-3">
                <label for="notes" class="form-label">Notes (optional)</label>
                <InputTextArea id="notes" class="form-control" @bind-Value="expense.Notes" rows="3" />
                <ValidationMessage For="() => expense.Notes" class="text-danger" />
            </div>

            <div class="d-flex gap-2">
                <button type="submit" class="btn btn-primary">Save Expense</button>
                <a href="/expenses" class="btn btn-secondary">Cancel</a>
            </div>
        </EditForm>
    </div>
</div>

@code {
    private Expense expense = new() { Date = DateTime.Today };

    private async Task HandleSubmit()
    {
        await ExpenseService.CreateAsync(expense);
        Navigation.NavigateTo("/expenses");
    }
}

Here is the complete Edit.razor:

@page "/expenses/edit/{Id:int}"
@using ExpenseTracker.Models
@using ExpenseTracker.Services
@inject IExpenseService ExpenseService
@inject NavigationManager Navigation
@rendermode InteractiveServer

<PageTitle>Edit Expense</PageTitle>

<h3 class="mb-4">Edit Expense</h3>

@if (expense is null)
{
    <div class="alert alert-warning">Expense not found.</div>
}
else
{
    <div class="row">
        <div class="col-md-6">
            <EditForm Model="expense" OnValidSubmit="HandleSubmit" FormName="EditExpense">
                <DataAnnotationsValidator />
                <ValidationSummary class="text-danger" />

                <div class="mb-3">
                    <label for="description" class="form-label">Description</label>
                    <InputText id="description" class="form-control" @bind-Value="expense.Description" />
                    <ValidationMessage For="() => expense.Description" class="text-danger" />
                </div>

                <div class="mb-3">
                    <label for="amount" class="form-label">Amount</label>
                    <InputNumber id="amount" class="form-control" @bind-Value="expense.Amount" />
                    <ValidationMessage For="() => expense.Amount" class="text-danger" />
                </div>

                <div class="mb-3">
                    <label for="category" class="form-label">Category</label>
                    <InputSelect id="category" class="form-select" @bind-Value="expense.Category">
                        <option value="">-- Select Category --</option>
                        <option value="Food">Food</option>
                        <option value="Transport">Transport</option>
                        <option value="Housing">Housing</option>
                        <option value="Entertainment">Entertainment</option>
                        <option value="Utilities">Utilities</option>
                        <option value="Healthcare">Healthcare</option>
                        <option value="Other">Other</option>
                    </InputSelect>
                    <ValidationMessage For="() => expense.Category" class="text-danger" />
                </div>

                <div class="mb-3">
                    <label for="date" class="form-label">Date</label>
                    <InputDate id="date" class="form-control" @bind-Value="expense.Date" />
                    <ValidationMessage For="() => expense.Date" class="text-danger" />
                </div>

                <div class="mb-3">
                    <label for="notes" class="form-label">Notes (optional)</label>
                    <InputTextArea id="notes" class="form-control" @bind-Value="expense.Notes" rows="3" />
                    <ValidationMessage For="() => expense.Notes" class="text-danger" />
                </div>

                <div class="d-flex gap-2">
                    <button type="submit" class="btn btn-primary">Update Expense</button>
                    <a href="/expenses" class="btn btn-secondary">Cancel</a>
                </div>
            </EditForm>
        </div>
    </div>
}

@code {
    [Parameter]
    public int Id { get; set; }

    private Expense? expense;

    protected override async Task OnInitializedAsync()
    {
        expense = await ExpenseService.GetByIdAsync(Id);
    }

    private async Task HandleSubmit()
    {
        if (expense is not null)
        {
            await ExpenseService.UpdateAsync(expense);
            Navigation.NavigateTo("/expenses");
        }
    }
}

Your app now has full CRUD functionality. You can create, read, edit, and delete expenses. Next up, you will build a spending dashboard that uses Dapper's aggregate SQL queries to summarize your spending by category.

Build the Spending Dashboard with Dapper

You have a fully working expense tracker with create, edit, and delete. Now it's time to answer the question every expense tracker exists to answer: where is your money going?

In this step, you'll build a spending dashboard powered by Dapper aggregate SQL queries. This is where the hybrid pattern really shines. EF Core handled your writes beautifully, but for reporting queries with SUM and GROUP BY, raw SQL through Dapper is faster and more flexible.

In this step, get ready to:
  • Implement dashboard query methods using Dapper aggregate SQL.
  • Create a Dashboard page with a monthly spending card and category breakdown table.
  • Update the navigation menu and set the Dashboard as the home page.
Add dashboard query methods to the service

Your ExpenseService.cs already has stub methods for GetMonthlyTotalAsync and GetSpendingByCategoryAsync that throw NotImplementedException. Time to replace those stubs with real Dapper queries.

  • In Services/ExpenseService.cs, find the GetMonthlyTotalAsync method.
  • Find this code:
    public async Task<decimal> GetMonthlyTotalAsync(int year, int month)
    {
        throw new NotImplementedException();
    }
  • Replace the entire method with the following implementation:
    public async Task<decimal> GetMonthlyTotalAsync(int year, int month)
    {
        using var connection = new SqliteConnection(_connectionString);
        var startDate = new DateTime(year, month, 1);
        var endDate = startDate.AddMonths(1).AddDays(-1);
        var result = await connection.QueryFirstOrDefaultAsync<decimal?>(
            "SELECT SUM(Amount) FROM Expenses WHERE Date >= @StartDate AND Date <= @EndDate",
            new { StartDate = startDate, EndDate = endDate });
        return result ?? 0;
    }

What does this code do?

This method calculates the total spending for a given month using a SQL SUM aggregate.

  • It builds a date range from the first day of the month to the last day.
  • Dapper's QueryFirstOrDefaultAsync<decimal?> returns null if there are no expenses. The ?? 0 converts that to zero.
  • The @StartDate and @EndDate parameters are passed safely via an anonymous object, preventing SQL injection.
  • Now find the GetSpendingByCategoryAsync method directly below.
  • Find this code:
    public async Task<IEnumerable<CategorySpending>> GetSpendingByCategoryAsync(int year, int month)
    {
        throw new NotImplementedException();
    }
  • Replace the entire method with the following implementation:
    public async Task<IEnumerable<CategorySpending>> GetSpendingByCategoryAsync(int year, int month)
    {
        using var connection = new SqliteConnection(_connectionString);
        var startDate = new DateTime(year, month, 1);
        var endDate = startDate.AddMonths(1).AddDays(-1);
        var results = await connection.QueryAsync<CategorySpending>(
            @"SELECT Category, SUM(Amount) as Total,
              ROUND(SUM(Amount) * 100.0 / (SELECT SUM(Amount) FROM Expenses WHERE Date >= @StartDate AND Date <= @EndDate), 1) as Percentage
              FROM Expenses
              WHERE Date >= @StartDate AND Date <= @EndDate
              GROUP BY Category
              ORDER BY Total DESC",
            new { StartDate = startDate, EndDate = endDate });
        return results;
    }

What does this query do?

This is a more complex aggregate query that groups expenses by category and calculates each category's share of total spending.

  • GROUP BY Category collapses all rows with the same category into one result row.
  • SUM(Amount) as Total adds up all amounts within each category.
  • The subquery (SELECT SUM(Amount) FROM Expenses WHERE ...) calculates the overall total so each category's percentage can be computed.
  • ROUND(..., 1) rounds the percentage to one decimal place.

Why Dapper instead of EF Core for these queries?

EF Core can do aggregates with LINQ, but the SQL it generates for nested subqueries and percentage calculations is often verbose and harder to optimize. With Dapper, you write the exact SQL you want and map it directly to your CategorySpending class.

This is the core of the hybrid pattern: use EF Core where its strengths matter (change tracking, migrations, validation) and Dapper where raw SQL is cleaner.

  • Save ExpenseService.cs.

Seeing red squiggly lines?

  • Make sure you kept the using Dapper; and using Microsoft.Data.Sqlite; imports at the top of the file.
  • Check that you replaced the entire method (including the signature line) and not just the body.

Help me fix compiler errors in my ExpenseService dashboard methods.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

using Dapper;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using ExpenseTracker.Data;
using ExpenseTracker.Models;

namespace ExpenseTracker.Services;

public class ExpenseService : IExpenseService
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;
    private readonly string _connectionString;

    public ExpenseService(IDbContextFactory<AppDbContext> contextFactory, IConfiguration configuration)
    {
        _contextFactory = contextFactory;
        _connectionString = configuration.GetConnectionString("DefaultConnection")!;
    }

    // READ operations use Dapper for performance and SQL flexibility
    public async Task<IEnumerable<Expense>> GetAllAsync()
    {
        using var connection = new SqliteConnection(_connectionString);
        return await connection.QueryAsync<Expense>(
            "SELECT Id, Description, Amount, Category, Date, Notes FROM Expenses ORDER BY Date DESC");
    }

    public async Task<Expense?> GetByIdAsync(int id)
    {
        using var connection = new SqliteConnection(_connectionString);
        return await connection.QueryFirstOrDefaultAsync<Expense>(
            "SELECT Id, Description, Amount, Category, Date, Notes FROM Expenses WHERE Id = @Id",
            new { Id = id });
    }

    public async Task<decimal> GetMonthlyTotalAsync(int year, int month)
    {
        using var connection = new SqliteConnection(_connectionString);
        var startDate = new DateTime(year, month, 1);
        var endDate = startDate.AddMonths(1).AddDays(-1);
        var result = await connection.QueryFirstOrDefaultAsync<decimal?>(
            "SELECT SUM(Amount) FROM Expenses WHERE Date >= @StartDate AND Date <= @EndDate",
            new { StartDate = startDate, EndDate = endDate });
        return result ?? 0;
    }

    public async Task<IEnumerable<CategorySpending>> GetSpendingByCategoryAsync(int year, int month)
    {
        using var connection = new SqliteConnection(_connectionString);
        var startDate = new DateTime(year, month, 1);
        var endDate = startDate.AddMonths(1).AddDays(-1);
        var results = await connection.QueryAsync<CategorySpending>(
            @"SELECT Category, SUM(Amount) as Total,
              ROUND(SUM(Amount) * 100.0 / (SELECT SUM(Amount) FROM Expenses WHERE Date >= @StartDate AND Date <= @EndDate), 1) as Percentage
              FROM Expenses
              WHERE Date >= @StartDate AND Date <= @EndDate
              GROUP BY Category
              ORDER BY Total DESC",
            new { StartDate = startDate, EndDate = endDate });
        return results;
    }

    // WRITE operations use EF Core for change tracking and validation
    public async Task CreateAsync(Expense expense)
    {
        using var context = await _contextFactory.CreateDbContextAsync();
        context.Expenses.Add(expense);
        await context.SaveChangesAsync();
    }

    public async Task UpdateAsync(Expense expense)
    {
        using var context = await _contextFactory.CreateDbContextAsync();
        context.Expenses.Update(expense);
        await context.SaveChangesAsync();
    }

    public async Task DeleteAsync(int id)
    {
        using var context = await _contextFactory.CreateDbContextAsync();
        var expense = await context.Expenses.FindAsync(id);
        if (expense is not null)
        {
            context.Expenses.Remove(expense);
            await context.SaveChangesAsync();
        }
    }
}
Create the Dashboard page

Now that the service methods are ready, you need a page to display the data. The Dashboard will show a prominent card with this month's total spending and a table breaking down spending by category.

  • In the Solution Explorer, right-click the Components/Pages folder.
  • Select Add, then Razor Component.
  • Name the file Dashboard.razor and click Add.
  • Replace all the default content with the following code:
@page "/"
@using ExpenseTracker.Services
@inject IExpenseService ExpenseService
@rendermode InteractiveServer

<PageTitle>Dashboard</PageTitle>

<h3 class="mb-4">Spending Dashboard</h3>

<div class="row mb-4">
    <div class="col-md-4">
        <div class="card text-white bg-primary">
            <div class="card-body">
                <h5 class="card-title">This Month's Spending</h5>
                <h2 class="card-text">@monthlyTotal.ToString("C")</h2>
                <p class="card-text"><small>@DateTime.Today.ToString("MMMM yyyy")</small></p>
            </div>
        </div>
    </div>
</div>

<h4 class="mb-3">Spending by Category</h4>

@if (categorySpending.Any())
{
    <table class="table table-striped">
        <thead>
            <tr>
                <th>Category</th>
                <th class="text-end">Total</th>
                <th class="text-end">Percentage</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var item in categorySpending)
            {
                <tr>
                    <td>@item.Category</td>
                    <td class="text-end">@item.Total.ToString("C")</td>
                    <td class="text-end">@item.Percentage.ToString("F1")%</td>
                </tr>
            }
        </tbody>
    </table>
}
else
{
    <div class="alert alert-info">No expenses recorded for this month. <a href="/expenses/create">Add your first expense!</a></div>
}

@code {
    private decimal monthlyTotal;
    private IEnumerable<CategorySpending> categorySpending = Enumerable.Empty<CategorySpending>();

    protected override async Task OnInitializedAsync()
    {
        var today = DateTime.Today;
        monthlyTotal = await ExpenseService.GetMonthlyTotalAsync(today.Year, today.Month);
        categorySpending = await ExpenseService.GetSpendingByCategoryAsync(today.Year, today.Month);
    }
}

What does this page do?

  • @page "/" makes this the home page. When users visit the root URL, they land here.
  • The Bootstrap card (bg-primary) displays the current month's total as a prominent currency value.
  • The table iterates through each CategorySpending result, showing the category name, total amount, and percentage of overall spending.
  • If no expenses exist for the month, a friendly alert with a link to add one appears instead.
  • OnInitializedAsync fetches both the monthly total and category breakdown when the page loads.
  • Save Dashboard.razor.

Seeing a build error about the route?

The default Blazor template includes a Home.razor page that also uses @page "/". If you get a duplicate route error, delete or rename the existing Home.razor file in Components/Pages.

Help me fix a duplicate route conflict in my Blazor app.

Update the navigation menu

The default navigation menu still links to the template pages. You need to update it to point to your Dashboard, Expenses list, and Add Expense pages.

  • In the Solution Explorer, expand Components/Layout and open NavMenu.razor.
  • Replace the entire file content with the following:
<div class="top-row ps-3 navbar navbar-dark">
    <div class="container-fluid">
        <a class="navbar-brand" href="">Expense Tracker</a>
    </div>
</div>

<input type="checkbox" title="Navigation menu" class="navbar-toggler" />

<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
    <nav class="flex-column">
        <div class="nav-item px-3">
            <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
                <span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Dashboard
            </NavLink>
        </div>
        <div class="nav-item px-3">
            <NavLink class="nav-link" href="expenses">
                <span class="bi bi-list-ul-nav-menu" aria-hidden="true"></span> Expenses
            </NavLink>
        </div>
        <div class="nav-item px-3">
            <NavLink class="nav-link" href="expenses/create">
                <span class="bi bi-plus-circle-fill-nav-menu" aria-hidden="true"></span> Add Expense
            </NavLink>
        </div>
    </nav>
</div>

What does this navigation do?

  • The brand name is now "Expense Tracker" instead of the default template name.
  • Three navigation links point to Dashboard (/), Expenses (/expenses), and Add Expense (/expenses/create).
  • Match="NavLinkMatch.All" on the Dashboard link ensures it only highlights when the user is on the exact root URL, not on every page.
  • Save NavMenu.razor.
  • Press F5 to run the application.
  • You should see the Spending Dashboard as the home page with a blue card showing "$0.00" (or your total if you added expenses earlier) and the navigation sidebar showing Dashboard, Expenses, and Add Expense links.
  • Click Add Expense in the sidebar and create a test expense (for example: "Lunch" in the Food category for $12.50 with today's date).
  • Click Dashboard in the sidebar to return to the home page.
  • You should now see the monthly total updated and a category breakdown table showing your expense's category, total, and 100.0% percentage.

Dashboard shows $0.00 even after adding expenses?

  • Make sure the expense date is in the current month. The dashboard filters by the current month's date range.
  • Check that you removed the throw new NotImplementedException() lines from both methods in ExpenseService.cs.
  • Verify the app rebuilt without errors in the Output window at the bottom of Visual Studio.

Help me debug why my dashboard is not showing expense data.

✔️ Awesome, I've got everything!

Great. Your full expense tracker is now functional with CRUD and dashboard reporting.

ⓧ I'd like to double check the full code

Here is the full Dashboard.razor:

@page "/"
@using ExpenseTracker.Services
@inject IExpenseService ExpenseService
@rendermode InteractiveServer

<PageTitle>Dashboard</PageTitle>

<h3 class="mb-4">Spending Dashboard</h3>

<div class="row mb-4">
    <div class="col-md-4">
        <div class="card text-white bg-primary">
            <div class="card-body">
                <h5 class="card-title">This Month's Spending</h5>
                <h2 class="card-text">@monthlyTotal.ToString("C")</h2>
                <p class="card-text"><small>@DateTime.Today.ToString("MMMM yyyy")</small></p>
            </div>
        </div>
    </div>
</div>

<h4 class="mb-3">Spending by Category</h4>

@if (categorySpending.Any())
{
    <table class="table table-striped">
        <thead>
            <tr>
                <th>Category</th>
                <th class="text-end">Total</th>
                <th class="text-end">Percentage</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var item in categorySpending)
            {
                <tr>
                    <td>@item.Category</td>
                    <td class="text-end">@item.Total.ToString("C")</td>
                    <td class="text-end">@item.Percentage.ToString("F1")%</td>
                </tr>
            }
        </tbody>
    </table>
}
else
{
    <div class="alert alert-info">No expenses recorded for this month. <a href="/expenses/create">Add your first expense!</a></div>
}

@code {
    private decimal monthlyTotal;
    private IEnumerable<CategorySpending> categorySpending = Enumerable.Empty<CategorySpending>();

    protected override async Task OnInitializedAsync()
    {
        var today = DateTime.Today;
        monthlyTotal = await ExpenseService.GetMonthlyTotalAsync(today.Year, today.Month);
        categorySpending = await ExpenseService.GetSpendingByCategoryAsync(today.Year, today.Month);
    }
}

Here is the full NavMenu.razor:

<div class="top-row ps-3 navbar navbar-dark">
    <div class="container-fluid">
        <a class="navbar-brand" href="">Expense Tracker</a>
    </div>
</div>

<input type="checkbox" title="Navigation menu" class="navbar-toggler" />

<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
    <nav class="flex-column">
        <div class="nav-item px-3">
            <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
                <span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Dashboard
            </NavLink>
        </div>
        <div class="nav-item px-3">
            <NavLink class="nav-link" href="expenses">
                <span class="bi bi-list-ul-nav-menu" aria-hidden="true"></span> Expenses
            </NavLink>
        </div>
        <div class="nav-item px-3">
            <NavLink class="nav-link" href="expenses/create">
                <span class="bi bi-plus-circle-fill-nav-menu" aria-hidden="true"></span> Add Expense
            </NavLink>
        </div>
    </nav>
</div>

Secret mission

Date-Range Filtering and Category Drill-Down

Your dashboard currently shows the current month's spending. What if you want to compare last month's groceries to this month's? In this bonus challenge, you'll add date-range filtering and a category dropdown to the dashboard using parameterized Dapper queries with conditional SQL building.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with a SQLite database, so there are no ongoing costs.

Resources you used:

  • The ExpenseTracker project folder (contains all source code, configuration, and NuGet packages).
  • The expenses.db SQLite database file (inside the project directory, stores your expense data).

Keep everything running

No action needed. Choose this if you're still actively building or want to keep logging expenses.

  • Your expenses.db file persists all your data between runs.
  • Next time you want to use the app, open the ExpenseTracker solution in Visual Studio and press F5 to start.
  • The NuGet packages are stored locally inside the project folder and take no additional disk space outside of it.

Pause - I'll come back to this later

Stop the running application to free up memory. Your files and data stay intact for when you return.

  • In Visual Studio, click the Stop Debugging button (red square) or press Shift+F5 to stop the app.
  • Close the Visual Studio solution if you want to free more memory.
  • Your expenses.db database and all source files remain on disk.

Delete - I don't want to use this again

Remove all project files and start fresh. There are no external cloud services to clean up.

  • In Visual Studio, click File, then Close Solution.
  • Open File Explorer (Windows) or Finder (macOS) and navigate to the folder where you created the ExpenseTracker project.
  • Delete the entire ExpenseTracker folder. This removes all source code, the expenses.db database, and all NuGet packages.
  • No accounts to close, no cloud resources to terminate, and no environment variables to remove.

Nice Work!

Nice Work!

You just built a fully functional Personal Expense Tracker using Blazor Web App, combining two of .NET's most powerful data access tools into the same application.

You've learned how to:

  • Build a Blazor Web App with Interactive Server rendering, including full CRUD pages with validated forms, Bootstrap styling, and navigation.
  • Use EF Core for all write operations (Create, Update, Delete) with migrations, change tracking, and data annotations validation.
  • Use Dapper for all read operations including list queries, single-record lookups, and aggregate dashboard queries with SUM and GROUP BY.
  • Secret Mission: Added date-range filtering and category drill-down with parameterized Dapper queries using DynamicParameters and conditional SQL building.

Ready to quiz yourself?