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.
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. So I followed a structured course, which led to my first ASP.NET Core app, GoGameShop . More practice came with another project, Freelancer .
The Expense Tracker brings together what I learned from those two projects, and it had a clear goal: keep the features simple, but build them with common architectural decisions and technologies. That's why it has complexity it didn't strictly need: a layered architecture, business logic kept out of the controllers, responses returned as DTOs, errors in the ProblemDetails standard, and more. The point was to learn these by implementing them.
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.
03
Architecture and design decisions
One of the first big decisions was how to organize the backend. The answer was Clean Architecture: four projects in the ASP.NET Core solution, with clear responsibilities and dependencies that point inward.
Why Clean Architecture?
For an application this small, Clean Architecture was probably more than I needed. A simpler layered structure would have worked just as well with less boilerplate. The reason was 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
PostgreSQL with Entity Framework Core as ORM. Users and categories have many expenses, while an expense has only one category and one user.
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.
The app sends the browser to Keycloak's sign-in page.
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.
07
Building the frontend
The frontend uses React, TypeScript, and Vite, with React Router handling navigation and TanStack Query managing API data.
One decision was to store expense filters 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.
- useExpenseQueryParams
- TanStack Query
- GET /api/expenses
Pick some filters, then press Send.
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
Automated tests verify behavior beyond successful requests. The backend uses 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, Vitest and React Testing Library test components, forms, API behavior and routing-related functionality. Docker Compose runs the frontend, backend, PostgreSQL and Keycloak together.
GitHub Actions automates builds, tests and container image publishing:
- push
- build
- unit tests
- integration tests
- Docker image
- container registry
09
Problems I ran into
Two of them stand out. In both, each part worked on its own, and the problem was in how they connected.
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.
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. I would later try and polish the UI for a better look and also smoother user experience.