All work

Case study  ·  Personal Expense Tracker

How I built
the Expense Tracker

It is a full-stack app for recording expenses, built with ASP.NET Core, React, TypeScript and PostgreSQL.

Backend
C#, ASP.NET Core, EF Core, PostgreSQL
Frontend
React, TypeScript, TanStack Query
Source
GitHub
E Expense Tracker Dashboard Expenses Log out Dashboard From To Total spending Expenses Average expense Largest expense Housing Travel Shopping Bills Food Apply Spending over time Spending by category Top categories Top expenses /summary /by-day /by-category /top-expenses
The dashboard, and the API call behind each panel

01

The idea

When I set out to learn how to write APIs with ASP.NET Core, the amount of information overwhelmed me. Dependency injection, the middleware pipeline, controllers, Entity Framework Core, migrations... The learning process was slow but consistent, and I learned the fundamental concepts by building GoGameShop and Freelancer . They ended up half finished, but the knowledge stayed with me.

I decided to build the Expense Tracker to put together everything I had learned so far in one complete package, and also to learn how to consume an API with React, which I hadn't done before. Overall, I'm happy with how it turned out, and it definitely leveled up my skills as a developer.

02

Planning the application

The core requirements and technical scope came first. The app stayed small on purpose, so the effort could go into the engineering underneath it.

Sep Oct Nov backend first then the frontend API Tests Frontend Sign-in Docker + CI Final fixes CRUD, dashboard Testcontainers React, TanStack Query, charts Keycloak, JWT Compose, GitHub Actions logic moved into services
Two months of commits, in the order they happened

03

Architecture and design decisions

I used a Clean Architecture-style structure: four projects in the ASP.NET Core solution, with clear responsibilities and dependencies that point inward.

API Infrastructure Application Domain endpoints, auth, configuration EF Core, PostgreSQL, migrations services, DTOs, validation rules, IFinanceDbContext User, Expense, Category implements the interface no package references at all
Four projects. Dependencies point inward, and Domain depends on nothing

Why this architecture?

For an application this small, it was probably more than I needed. A simpler layered structure would have worked just as well with less boilerplate. The reason I chose it was to get hands-on experience separating responsibilities, managing dependencies, and working with interfaces and dependency injection. The trade-off was additional complexity, but since this was primarily a learning project, it was worth it.

04

Database design

The app uses PostgreSQL with Entity Framework Core as its ORM. Users and categories have many expenses, while an expense has only one category and one user.

Expense Id UserId CategoryId Amount Description ExpenseDate CreatedAt UpdatedAt Guid Guid int decimal(18,2) string DateOnly DateTime DateTime index (UserId, ExpenseDate) User unique Id ExternalIdentityId Email CreatedAt Expenses Guid string string DateTime many Category unique Id Name CreatedAt int string DateTime Food, Bills, Other, Travel, Transport, Housing, Shopping, Entertainment, Healthcare no rounding errors only the day audit times, in UTC one user's expenses, by date
One expense, and the choices inside it

05

Authentication and security

Authentication was one of the more involved parts of the application. Keycloak is the identity provider, using OpenID Connect and the Authorization Code Flow with PKCE for the React frontend. After sign-in, the frontend sends an access token with its API requests, and the ASP.NET Core backend validates the JWT before allowing access to protected endpoints.

Step 1 of 7
React app Keycloak ASP.NET Core PostgreSQL 1 redirect to sign in 2 user signs in here the password never reaches my app 3 back with a token 4 GET /api/expenses + token 5 validate the JWT 6 WHERE UserId = you 7 only your expenses
Signing in, then calling the API

The first time a Keycloak user calls the API, the backend creates a matching local user.

06

The REST API

The API has endpoints for managing expenses, retrieving categories, and generating dashboard reports.

Expense retrieval uses LINQ to apply filtering, sorting, and pagination before the database query runs. DTO projections return only the fields needed by the frontend instead of exposing EF Core entities.

The dashboard has four endpoints that share one query, filtered by the signed-in user and an optional date range. The totals are calculated in PostgreSQL through EF Core, so the API doesn't load every expense into memory.

Pick an endpoint
every expense Where(e => e.UserId == userId) date range PostgreSQL runs it category, amount, search OrderBy(…) Skip + Take Select(DTO) ToListAsync() GroupBy(e => 1) Sum, Count, Max FirstOrDefaultAsync() GroupBy(category) Sum(amount) ToListAsync() GroupBy(ExpenseDate) Sum(amount) ToListAsync() OrderByDescending(amount) Take(10) Select(DTO) ToListAsync()
What each endpoint runs, and what comes back

07

Building the frontend

The frontend uses React, TypeScript, and Vite, with React Router handling navigation and TanStack Query managing API data.

Expense filters are stored in URL search parameters rather than only in component state. This allows users to refresh the page or share a filtered view without losing their selections.

/expenses?pageSize=5
  1. useExpenseQueryParams
  2. TanStack Query
  3. GET /api/expenses

Pick some filters, then press Send.

Sample data in your browser, so this doesn't call the real API

TanStack Query handles fetching, caching, loading states, and request errors, keeping server data separate from local UI state.

On the dashboard, Recharts displays spending trends and category breakdowns.

08

Testing and development infrastructure

I added automated tests to verify behavior beyond successful requests. In the backend, I used xUnit, with integration tests that run the actual API against PostgreSQL through Testcontainers. These cover CRUD operations, validation, filtering, pagination, dashboard calculations and access restrictions between users.

On the frontend, I used Vitest and React Testing Library to test components, forms, API behavior and routing-related functionality. Docker Compose can run the full app locally.

Browser web api keycloak db Nginx, :5173 ASP.NET Core, :5048 sign-in, :8080 PostgreSQL, :5433 docker compose up integration tests: real API, real Postgres Vitest + React Testing Library components, forms, routing swapped for a test handler, so real sign-in isn't tested yet
What runs, and how much of it the tests reach

GitHub Actions automates builds, tests and container image publishing:

  1. push
  2. build
  3. unit tests
  4. integration tests
  5. Docker image
  6. container registry

09

Problems I ran into

Docker + Keycloak

Every request returned 401

What happened Once everything ran in Docker Compose, every signed-in request failed with 401, even with a token that looked valid.

Why Keycloak had two addresses. From my Mac it was at localhost:8080, but the API, inside Docker's network, reached it at keycloak:8080. A token records where it came from in its iss (issuer) claim, and the API checks that claim. The tokens said localhost, the API expected keycloak, and nothing matched.

Browser Keycloak API localhost:8080 keycloak:8080 inside Docker JWT iss localhost:8080 expected keycloak:8080 401
One Keycloak, two names

The fix Two Keycloak settings in compose.yaml:

# Every token names the same issuer…
KC_HOSTNAME: http://localhost:8080
# …while the API still reaches Keycloak by its Docker name
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"

ASP.NET Core + JWT

The sub claim that wasn't there

What happened A smaller version of the same lesson. Looking up "sub" on a valid token found nothing and threw an error.

Why ASP.NET Core renames some inbound claims by default, so sub had become a long URL-style name.

The fix Setting MapInboundClaims = false, so "sub" stays "sub".

10

Future work

There are still a few areas I'd like to work on:

  • Production deployment: The application currently runs locally. My aim is to deploy it later.
  • End-to-end testing: My existing tests cover individual components and API behavior, but I'd like to test complete user flows, including authentication.
  • Monitoring: Adding structured logging and monitoring would make diagnosing problems easier in a deployed environment.
  • UI polish: I'm fond of working on layouts and UI components. Later, I'd like to polish the UI for a better look and a smoother user experience.