A shared-budgeting backend for a group, built on envelope budgeting, structured as a Modular Monolith on .NET 10. Every core backend concern (authentication, realtime, caching, CDN, messaging, concurrency) gets one slice that actually runs, kept minimal but done properly.
- Overview
- Current state
- What is demonstrated
- Architecture
- Transaction write path
- Tech stack
- Quick start
- Project layout
- Testing
- Cache benchmark
- Architecture decisions
- Roadmap
- License
Finmy lets a group manage shared money the envelope way: income is divided into budget envelopes per spending category (food, utilities, tuition), and each transaction draws down the matching envelope. Several members of a Space see and spend against the same set of budgets, with Owner, Member and Viewer roles.
The hard problem is several people spending from the same nearly-empty envelope at once: two concurrent transactions must not push the balance below what is left. That part works. The solution is optimistic concurrency on the envelope balance, plus Wolverine's transactional outbox so recording a transaction and publishing its event happen in one transaction. The concurrency token is an int Version column the domain increments in every mutating method, mapped with IsConcurrencyToken(), deliberately not Postgres xmin (reasoning under Architecture). An integration test runs the two-concurrent-transactions scenario against a real Postgres via Testcontainers.
The repository previously modelled event ticketing; the reason for moving to shared budgeting is in ADR-0006.
A personal project under active construction, on its way to a real deployment but not there yet. This section separates what runs from what is planned so nobody has to guess.
Built and running:
- Modular Monolith skeleton:
Finmy.Apias the composition root,IModulefor self-registration, one DbContext per module on its own schema (identity,budgeting,ledger, pluswolverinefor the message store). - Identity, all four layers: registration, login, JWT access tokens. Refresh-token rotation, with reuse of a revoked token revoking the user's entire chain. Tokens are generated from an RNG and stored as SHA-256 hashes behind a unique index on
TokenHash. Admin and User roles plus a default admin seeded through anIHostedService, credentials read from configuration. Endpoints under/api/v1/identity:/register,/login,/refresh,/logout,/me,/admin-only. The module has been through a security review with the findings fixed. - Authorization: every endpoint requires an authenticated user by default (
FallbackPolicy), except an explicitAllowAnonymousallowlist: register, login, refresh, logout, the health endpoints, and Scalar/OpenAPI in Development (ADR-0016). - Budgeting: full envelope CRUD (create, read, paginated list, update, delete) and a monthly allocation report, with categories seeded in a migration.
- Caching: HybridCache cache-aside for the envelope list and the monthly report with per-entry TTLs, tag-based invalidation on writes (
BudgetingCachePolicyplusRemoveByTagAsync), and output caching with Brotli/Gzip compression on the two read-heavy endpoints, varied by the caller's identity so authorization did not have to turn caching off, evicted through theIOutputCacheInvalidatorport. - CDN and object storage: receipt upload to MinIO over the S3 API (AWSSDK.S3), validated by magic bytes, with a server-generated object key and a
Receiptpointer row in Postgres.POST /api/v1/receiptsuploads;GET /api/v1/receipts/{id}answers 302 with a presigned URL andCache-Control. - Realtime: a strongly-typed
Hub<IEnvelopeClient>at/api/v1/hubs/envelopes, one group per envelope, pushingEnvelopeUpdated,EnvelopeAlertandEnvelopeDeleted. The Application layer only knows theIEnvelopeRealtimeNotifierport, not SignalR. - Balance and overspend protection:
EnvelopeholdsSpent, computesRemainingfromAllocated - Spent, and mutates throughSpend,ReleaseandFund. Spending past the balance returns a domain error rather than going negative.
- Caching: HybridCache cache-aside for the envelope list and the monthly report with per-entry TTLs, tag-based invalidation on writes (
- Ledger: the
Transactionaggregate withTransactionState(Posted,Reversed,Confirmed),POST /api/v1/transactionsanswering 202 Accepted and processing asynchronously.GET /api/v1/transactions/{id}/statusis the status resource and answers303 See Otheronce it settles;GET /api/v1/transactions/{id}is the transaction itself (ADR-0015). Status lives in Postgres, with a background service pruning rows past their retention window.- Messaging and outbox: Wolverine in-process, Dynamic codegen in development and Auto in production (ADR-0013), message store on the
wolverineschema,AddDbContextWithWolverineIntegrationso writing aTransactionand enqueuing its message share one transaction.DbUpdateConcurrencyExceptiongets its own retry-with-cooldown policy before the message moves to the error queue. - Idempotency:
Idempotency-KeyonPOST /api/v1/transactionsbacked byIIdempotencyStore, with a request fingerprint so a reused key carrying a different payload is rejected with 422 rather than silently replayed. On the consumer side, aProcessedTransactiontable makes the Budgeting handler idempotent, so a redelivered message does not deduct twice.
- Messaging and outbox: Wolverine in-process, Dynamic codegen in development and Auto in production (ADR-0013), message store on the
- Integration events in
Finmy.Contracts:TransactionPostedEvent,EnvelopeOverspentEvent,EnvelopeBalanceChangedEvent. The full chain is described under Transaction write path. - Operations:
GET /health/liveandGET /health/ready(the latter probing Postgres, Redis and S3/MinIO), a global authenticated fallback policy, rate limiting on all endpoints with a tighter policy on login/register/refresh, and a migration strategy that survives more than one replica (ADR-0014). - Observability: Serilog writing structured JSON to stdout, enriched with trace and correlation ids; OpenTelemetry tracing and metrics across ASP.NET Core, HttpClient, Npgsql, Redis and Wolverine, exported over OTLP when a collector is configured; a dedicated
ActivitySource(Finmy.AntiOverspend) so one transaction traces across both Ledger and Budgeting; business metrics (finmy.transactions.recorded,finmy.envelopes.overspent,finmy.envelope.concurrency_conflicts,finmy.outbox.backlog); and a self-hosted Grafana stack (Prometheus, Tempo, Loki, an OpenTelemetry Collector) with a provisioned dashboard and four alert rules (ADR-0017). - Tests: unit tests for the Envelope domain (create, update, spend, fund),
EnvelopeService, cache policy, alert policy, the receipt validator and the Transaction domain; integration tests for the concurrent-spend race, the async transaction lifecycle, rate limiting and pruning, all running against real Postgres, Redis and MinIO through Testcontainers. Result<T>,ErrorandErrorTypein SharedKernel, aGlobalExceptionHandlerreturning ProblemDetails without leaking stack traces, andValidationFilter<T>with FluentValidation rejecting bad input at the endpoint.- OpenAPI plus Scalar UI in Development.
- Docker Compose for the whole system: PostgreSQL 17, Redis 8, MinIO, a one-shot migration service, and the API itself.
git clonethendocker compose upbrings up a working system with no manual steps. - A multi-stage Dockerfile publishing a non-root, framework-dependent image, and GitHub Actions for build, test, architecture tests, coverage, CodeQL and a vulnerable-package gate, with a release workflow pushing tagged images to GHCR.
- Sixteen ADRs plus
docs/naming-conventions.md, anddocs/TECH-DEBT.mdlisting known gaps.
Not built yet: Space, Account, Member and per-Space authorization; CSV statement import with deduplication; the Helm/k3s deployment.
| Concern | Module | How | Status |
|---|---|---|---|
| Authentication | Identity | JWT plus refresh-token rotation, role-based authorization | Done |
| Error handling | whole system | Result<T> plus ProblemDetails plus FluentValidation |
Done |
| CRUD and database | Budgeting | EF Core 10: envelope CRUD and monthly report, pagination, validation | In progress |
| Caching | Budgeting | HybridCache (L1 in-memory, L2 Redis), cache-aside with tag invalidation, output caching and compression | Done |
| CDN / object storage | Budgeting | Receipt upload to MinIO (S3 API), served through a cache layer with presigned URLs | Done |
| Realtime | Budgeting | SignalR pushes new balances and alerts to watching clients | Done |
| Messaging | Ledger | Wolverine in-process: async transaction recording plus transactional outbox | Done |
| Concurrency | Ledger + Budgeting | Optimistic concurrency on Envelope.Version, compensating reversal on overspend |
Done |
| Idempotency | Ledger | Idempotency-Key with request fingerprint, plus a consumer dedup table |
Done |
| Observability | whole system | Serilog structured logging, OpenTelemetry tracing and metrics, self-hosted Grafana stack | Done |
| Operations | whole system | Docker multi-stage, GitHub Actions, Helm on k3s | Planned |
Finmy is a Modular Monolith: one process, source split into independent modules. Each module contains its own Domain, Application, Infrastructure and API endpoints, and modules communicate only through integration events in Finmy.Contracts, never by referencing each other's internals.
All three modules have working code. Space is the root aggregate for sharing: it will own Account, Category, Envelope and Transaction, and it is the authorization boundary. Today SpaceId exists only as a column on Transaction; the aggregate itself is not written.
┌─────────────────────────────────────────────┐
│ Finmy.Api (host) │
│ composition root │
├───────────┬───────────────┬─────────────────┤
│ Identity │ Budgeting │ Ledger │
│ (done) │ (in progress) │ (in progress) │
│ │ Envelope/ │ Transaction │
│ │ Category/Receipt│ (outbox) │
└───────────┴───────────────┴─────────────────┘
│ │ │
└──── Wolverine message bus ┘
(integration events)
│
┌─────────┬───────┴────────┬──────────┐
PostgreSQL Redis MinIO SignalR
The envelope balance is written only by Budgeting. Ledger never touches the envelope table; it publishes an event and waits for the answer. This single-writer rule is why overspend protection lives in Budgeting rather than Ledger, even though the business rule sounds like Ledger's job. The full reasoning, including what eventual consistency costs here, is in ADR-0010.
The concurrency token is a self-managed int Version rather than xmin through IsRowVersion(). xmin forces hand-editing a meaningless AddColumn out of every migration, and its value does not survive a dump and restore. The self-managed version has its own cost: forgetting Version++ in a new mutating method silently removes the protection while the build stays green, so every mutating method needs a test asserting the version increments.
Module boundaries are enforced by NetArchTest in tests/Finmy.ArchitectureTests, alongside a Roslyn guard that fails the build when a mutating Envelope method forgets to bump Version.
Reasoning behind the choices is in the ADRs.
This slice touches most of the difficult parts of the repository, so it is written out here to read alongside the code.
- The client calls
POST /transactions, optionally with anIdempotency-Key. A repeated key returns the original outcome instead of creating a second transaction; a repeated key with a different payload is rejected with 422. The endpoint generates a v7Guid, marks the request pending and answers 202 Accepted with a status URL. RecordTransactionHandlerwrites theTransactionin statePostedand enqueuesTransactionPostedEventin the same transaction, through Wolverine's outbox. If the write fails, the event never leaves, so there is no case where a transaction disappears but its event was published.- Budgeting receives the event in
TransactionPostedHandler, which records the transaction id in a dedup table so a redelivery is a no-op. Expenses callEnvelope.Spend; income callsEnvelope.Fundto add budget. The two are deliberately separate:Fundadds toAllocated, whileRelease(a refund) reducesSpent. - If the balance is short, Budgeting publishes
EnvelopeOverspentEvent. Ledger handles it inEnvelopeOverspentHandlerand reverses the transaction toReversed, while Budgeting'sEnvelopeOverspentAlertHandlerpushes an alert to the client. - If the deduction succeeds, Budgeting publishes
EnvelopeBalanceChangedEvent. Two Budgeting handlers consume it: one evicts cache by tag, the other pushes the new balance over SignalR and adds an alert when the balance drops below 20% of the allocation (BudgetingAlertPolicy). Because ofMultipleHandlerBehavior.Separated, each handler runs in its own chain, so one failing does not take the other down. - Ledger listens to that same event in
TransactionConfirmedHandlerand only then flips the transaction toConfirmed.Confirmedtherefore means the money was actually deducted in Budgeting, not merely that the row was written.
When two transactions run concurrently against a nearly-empty envelope, the loser gets a DbUpdateConcurrencyException. Wolverine retries three times with a cooldown, re-reading the current balance each attempt, and moves the message to the error queue once retries are exhausted.
In use today:
| Layer | Technology |
|---|---|
| Runtime | .NET 10, C# 14 |
| Web | ASP.NET Core 10 (Minimal API) |
| ORM / database | EF Core 10, PostgreSQL 17 (Npgsql) |
| Auth | ASP.NET Core Identity plus JWT Bearer |
| Messaging | Wolverine 6 (mediator, bus, transactional outbox) |
| Realtime | SignalR |
| Validation | FluentValidation |
| Caching | HybridCache (L1 in-memory, L2 Redis), output caching |
| Object storage | MinIO through AWSSDK.S3 |
| API docs | OpenAPI plus Scalar (Development only) |
| Tests | xUnit v3 on Microsoft Testing Platform, NSubstitute, Shouldly, Testcontainers |
| Logging | Serilog (console JSON, OTLP to a collector) |
| Tracing / metrics | OpenTelemetry SDK, OTLP export |
| Infrastructure | Docker Compose (PostgreSQL, Redis, MinIO, and an optional Prometheus/Tempo/Loki/Grafana stack) |
Quality gates: .NET analyzers, SonarAnalyzer, Roslynator, NetArchTest, and code coverage through the Microsoft Testing Platform collector. Planned: Mapster, Helm on k3s.
On licensing: the project deliberately avoids libraries that moved to commercial licenses in 2025 (MediatR, AutoMapper, MassTransit, Moq, FluentAssertions) and uses equivalent replacements. Details in ADR-0003.
On money: amounts are stored as
decimalwith rounding handled explicitly. Automatic import from Vietnamese banks is not practical without widespread open banking, so input comes from CSV or statement upload, or manual entry.
- .NET 10 SDK
- Docker and Docker Compose
docker compose up brings up the whole system: PostgreSQL, Redis, MinIO, a one-shot migrate service that applies every module's EF migrations, then the API itself. No .env file is required; every value has a development default baked into the compose file.
git clone https://github.com/tthanhtung92/finmy.git
cd finmy
docker compose -f docker/docker-compose.yml up -d --build
# API: http://localhost:8080/health/live
# MinIO console: http://localhost:9001api runs with ASPNETCORE_ENVIRONMENT=Production, exercising the same Wolverine codegen path described in ADR-0013. Scalar and the raw OpenAPI document are only mapped in Development, so neither is reachable through this path.
To override any default (a real MinIO or Postgres password, for instance), copy .env.example to .env and pass --env-file .env alongside -f docker/docker-compose.yml.
Tagged releases publish to ghcr.io/tthanhtung92/finmy.
For pgAdmin and RedisInsight alongside Postgres and Redis, use docker/docker-compose.local.yml instead; it does not currently run the API (see docs/TECH-DEBT.md).
For traces, metrics, logs and dashboards, add the observability compose file on top:
docker compose -f docker/docker-compose.yml -f docker/docker-compose.observability.yml up -d --build
# Grafana: http://localhost:3000
# Prometheus: http://localhost:9090This adds Prometheus, Tempo, Loki, Grafana and an OpenTelemetry Collector, and points the API at the collector; the base compose file is untouched, so plain docker compose up still works with no observability stack running at all.
For local development with Scalar and hot reload, run the dependencies through compose and the API from the .NET CLI:
docker compose -f docker/docker-compose.yml up -d postgres redis minio
# create .env at the repo root from the template, then set the connection
# strings and the Jwt signing key through User Secrets (all three DBs and
# the MinIO credentials ship empty in appsettings.json)
cp .env.example .env
dotnet user-secrets set "ConnectionStrings:IdentityDb" "<connection string>" --project src/Bootstrap/Finmy.Api
dotnet ef database update -p src/Modules/Identity/Finmy.Identity.Infrastructure -s src/Bootstrap/Finmy.Api
dotnet ef database update -p src/Modules/Budgeting/Finmy.Budgeting.Infrastructure -s src/Bootstrap/Finmy.Api
dotnet ef database update -p src/Modules/Ledger/Finmy.Ledger.Infrastructure -s src/Bootstrap/Finmy.Api
dotnet run --project src/Bootstrap/Finmy.Api
# Scalar API docs: http://localhost:5079/scalarWolverine's tables on the wolverine schema are created at startup and need no migration.
finmy/
├── src/
│ ├── Bootstrap/Finmy.Api/ # the only host, composition root
│ ├── Modules/
│ │ ├── Identity/ # auth, JWT, refresh-token rotation
│ │ │ ├── Finmy.Identity.Domain/
│ │ │ ├── Finmy.Identity.Application/
│ │ │ ├── Finmy.Identity.Infrastructure/
│ │ │ └── Finmy.Identity.Api/
│ │ ├── Budgeting/ # envelopes and balances, caching, uploads, SignalR
│ │ └── Ledger/ # transactions, Wolverine outbox, reversal, idempotency
│ └── Shared/
│ ├── Finmy.SharedKernel/ # Result<T>, Error, ErrorType
│ ├── Finmy.Modularity/ # IModule, ResultExtensions, ValidationFilter
│ └── Finmy.Contracts/ # integration events between modules
├── tests/
│ ├── Finmy.UnitTests/ # domain, services, cache and alert policies, validators
│ └── Finmy.IntegrationTests/ # real Postgres through Testcontainers
├── bench/ # k6 script for the cache benchmark
├── docker/ # compose files
├── deploy/ # Helm chart, Terraform, SOPS-encrypted secrets
└── docs/ # ROADMAP, TECH-DEBT, naming conventions, ADRs
Space, Account and the rest arrive with the roadmap.
dotnet test Finmy.slnx # 112 tests; the integration suite needs Docker
pwsh scripts/coverage.ps1 # coverage, failing below the recorded floorUnit tests cover the Envelope domain (create, update, spend, fund), EnvelopeService, the cache and alert policies, the receipt validator and the Transaction domain. Architecture tests hold the module boundaries and the Version++ invariant. Integration tests run against real containers: the concurrency race straight against Postgres, and the full anti-overspend loop over HTTP through WebApplicationFactory with Postgres, Redis and MinIO behind it, waiting on Wolverine's tracked session rather than on a sleep.
The test projects run on Microsoft Testing Platform rather than VSTest. Filters go after --: --filter-class, --filter-method, --filter-query. The older --filter "FullyQualifiedName~X" syntax is accepted, matches nothing, and reports "Zero tests ran" with exit code 5, which looks like a broken runner and is a broken filter.
Architecture tests run alongside the rest of the suite, so a broken module boundary shows up as a failing build rather than in review.
Measured with k6 against GET /envelopes, comparing two states of the same endpoint: cache miss (the request goes all the way to Postgres) and cache hit (the response comes straight from the output cache).
Throughput and latency, 50 VUs for 30 seconds per state, http_req_failed at zero:
| Metric | Before cache (miss) | After cache (hit) | Difference |
|---|---|---|---|
| Throughput | 1238 req/s | 32722 req/s | about 26x |
| p95 latency | 58.5 ms | 3.0 ms | about 19x lower |
| p99 latency | 81.0 ms | 5.6 ms | about 14x lower |
| Mean latency | 40.2 ms | 1.4 ms | about 29x lower |
Payload after response compression, on a list with pageSize=100:
| Encoding | Size | Versus uncompressed |
|---|---|---|
| None | 16545 B | 1x |
Brotli (br) |
2517 B | 6.6x smaller |
| Gzip | 3313 B | 5.0x smaller |
Conditions: AMD Ryzen 7 4800H, Windows 11, host running .NET 10 in Release on localhost, k6 v2.1.0, 50 VUs, 30 seconds per state, roughly 60 seeded envelopes. Since k6 and the host share a machine with no real network in between, these numbers compare miss against hit on one configuration; they are not latencies a user would see over the internet.
Significant decisions are recorded as ADRs:
- ADR-0001: Modular Monolith instead of microservices
- ADR-0002: Wolverine as mediator, message bus and transactional outbox
- ADR-0003: Avoiding commercially licensed libraries; Mapster, NSubstitute, Shouldly
- ADR-0004: Identity module boundary via Option A (dependency inversion through IIdentityService)
- ADR-0005: JWT short-name claims with IdentityClaimTypes as the source of truth
- ADR-0006: Moving the domain to shared envelope budgeting
- ADR-0007: Naming conventions for folders, files and namespaces
- ADR-0008: Serving receipt images via presigned URLs with a CDN in front of the origin
- ADR-0009: An int
Versioncolumn managed by the domain as the concurrency token, notxmin - ADR-0010: Budgeting owns the envelope balance; overspend protection is eventually consistent
- ADR-0011: Recording a transaction is an async 202 Accepted with a status resource
- ADR-0012: Stay a modular monolith through the production phases; extract Identity first if a split becomes necessary
- ADR-0013: Run production Wolverine handlers with
TypeLoadMode.Auto, notStatic - ADR-0014: Migration strategy for multiple replicas
- ADR-0015: The transaction status resource splits to a sub-resource and answers
303 See Other - ADR-0016: Authenticated by default, with an explicit anonymous allowlist
- ADR-0017: Observability is OTLP-first, with a shared ActivitySource in SharedKernel and alerting provisioned in Grafana
- ADR-0018: Self-hosted deployment shape: in-cluster data tier, SOPS-encrypted secrets, Helm-on-tag CD
The phase plan is in docs/ROADMAP.md; known gaps are tracked in docs/TECH-DEBT.md.
- Foundations, solution layout, module skeleton
- Identity: auth, JWT, refresh-token rotation
- Budgeting: envelope CRUD and monthly report
- HybridCache with tag invalidation, MinIO uploads, output caching, cache benchmark
- SignalR realtime, Wolverine in-process, async 202 writes, transactional outbox
- Overspend protection with a race-condition test, and the full event chain
- Idempotency:
Idempotency-Keyon writes plus a consumer dedup table - Build and quality gates: analyzers, coverage, NetArchTest, HTTP-level integration tests
- Packaging and CI/CD: Dockerfile, GitHub Actions, image publishing
- Production hardening: health checks, authorization, rate limiting, API versioning, durable status store
- Observability: Serilog plus OpenTelemetry into a self-hosted Grafana stack
- Deployment: Helm on k3s, Terraform, TLS, encrypted secrets
- Space, membership and per-Space authorization
Released under the MIT License.