Every repository, service, Blazor page/dialog, and test now uses
async/await. Single atomic diff; the codebase does not compile in
intermediate states.
- BaseRepository: LoadListAsync/LoadAsync/SaveAsync return Task<T>;
per-file locks use SemaphoreSlim so waiters can await; Save
serialises to a MemoryStream sync (XmlSerializer has no async
form), then File.WriteAllBytesAsync + sync File.Move.
- RepositoryCache.GetOrLoadAsync takes a Func<Task<List<T>>>.
- All 7 repository interfaces + implementations async.
- All service interfaces + implementations async (except vendor
IFxService and stateless IFxConverter / SettingsService).
- Every Blazor OnInitializedAsync switches to await base.
- Test suite fully async, 42 tests pass.
AccountRepository.EnsureAccountsFile keeps two .GetAwaiter().GetResult()
bridges because it runs from the constructor.
Null-render guard follow-up (folded in):
Blazor now renders the component once with fields at their initial
values while OnInitializedAsync awaits — so fields declared `= null!`
are actually null on that first render and things like
`accounts.GroupBy(...)` throw ArgumentNullException. Fixed across
Transactions, BalanceReport, BalanceSheetReport, ProfitLossReport,
DetailReport, Assets, Spendings, SpendingsOverTime, TransactionDialog,
TransactionSplitDialog, and BookingRuleDialog:
- Collection fields initialise to [] so first-render loops are empty.
- Single-object data fields become nullable; the razor wraps
consumption in `@if (field is null) { <p>Lädt…</p> return; }`.
- <PlotlyChart> guarded behind a null check on config/layout/data so
Plotly.Blazor's @bind doesn't see nulls.
- Header/footer strings initialise to "" instead of null!.
Architectural hygiene on a single-user local Blazor Server app: the
observed win is one File.ReadAllBytesAsync and one
File.WriteAllBytesAsync per Load/Save, and after PR D each file is
loaded at most once per SignalR circuit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
16 KiB
16 KiB
AccountingNext — Technical Improvement Plan
Analysis and phased plan produced 2026-07-02. See conversation history for full context.
Findings
1. Correctness bugs (must fix)
- Shared mutable state across users.
SettingsServiceis registered asSingleton(Schaad.Accounting.UI/Extensions.cs:21) but holds mutableyear/mandatorfields (Schaad.Accounting.Services/SettingsService.cs:13-14). Any user switching year/mandator changes it for all connected Blazor Server users. TrySetYearrollback is broken.SettingsService.cs:33capturesvar oldYear = year;— that's the parameter, notthis.year. The "rollback" restores the same value that just failed.ChartServicemutates a shared service.GetAccountExpensesPerMonth(accountId, year)(ChartService.cs:87) callssettingsService.TrySetYear(year)thenSetYear(DateTime.Now.Year)to reload prior-year data. Global mutation on a singleton — guaranteed to race in production.DummyFxServicehas a typo.DummyFxService.cs:13—currencies.Contains(fromCurrency) && currencies.Contains(fromCurrency)(both sides checkfromCurrency). Also it silently returns the raw amount for supported currencies, so all "CHF conversions" are no-ops.TransactionRepository.GetTransactionmutates the entity.TransactionRepository.cs:67-81dividesValuebyFxRateon read, andSaveTransactionmultiplies byFxRateon write. Also silently NREs when the id is not found (line 73 dereferencestransaction).ViewService.GetTransactionViewList(accountId)mutates transactions.ViewService.cs:172flipst.Value *= -1on the loaded transaction.FileService.GetTransactionListCsvdoes the same (FileService.cs:137). These are the shared objects returned by the repository — later reads see wrong signs.- DI double-registration.
Extensions.csregistersIChartService,IViewService,IFileService, statement services, andIFxServicein bothAddRepositoriesandAddServices. AlsoAddServicesregistersPdfParsingServiceas its own key (services.AddSingleton<PdfParsingService, PdfParsingService>()) instead ofIPdfParsingService. - Magic numbers instead of
ClassIds.ProfitLossReport.razor.cs:25-26uses.Class == 3/.Class == 4. ClassIdsdoc/code mismatch.CLAUDE.mdstatesActiva=1000, Passiva=2000, …, butClassIds.csdefines them as1,2,3,4(matchesAccount.Class = Number / 1000).- Non-atomic XML writes, no locking.
BaseRepository.Savewrites directly to the target path. Interrupted writes corrupt data. Two concurrent saves interleave. No temp-file+rename, noFileSharelock. AccountRepository.EnsureFileExisitscallsSaveAccountin a loop. Each call re-loads and re-serializes the whole account list.ProfitLossReport/BalanceSheetReportcompute totals by re-summing balances thatViewServicealready computes — but with subtle differences. Two sources of truth for the same number.
2. Reliability / maintainability
- No async I/O. Every XML read/write is synchronous, blocking the SignalR hub thread in Blazor Server.
- No caching. A single page render calls
GetAccountList()andGetTransactionList()many times. Every one is a fresh file read + XML deserialization. - O(N·M) lookups.
ViewService.cs:138-139—accountList.Single(...)inside aSelectover all transactions. Should be a dictionary keyed by id. - No logging.
ILoggeris not used anywhere. - No error surface. Repositories throw or return
null; UI dereferences with!. - Anemic domain model +
Copymethods.Account.Copy,Transaction.Copy,SubClass.Copyetc. are hand-rolled property copies used to merge edits into loaded entities. - Vendor coupling in the Domain project.
Schaad.Accounting.CommonreferencesSchaad.Finance.Api.dll.IChartService,IFileService, andIViewServicelive inCommonand depend onSchaad.Finance.Apitypes. - Duplicated formatting logic.
ToFormattedString(decimal)exists inExtensions.csand again inFileService.cs. - Culture setup in
Program.csruns afterMapRazorComponentsand just setsDefaultThreadCurrentCultureglobally. Should beRequestLocalizationOptionsmiddleware. - Constructors doing I/O.
AccountRepository,SubclassRepository,SplitPredefinitonRepository,BookingRuleRepositorycallEnsureFileExisitsin the constructor. - Typos leak into public API.
EnsureFileExisits,SplitPredefinitonRepository. - Dead / redundant code.
AccountRepository.EnsureFileExisitsusesnewto hide the base method; itsfile.IndexOf("Accounts") > -1guard is redundant.Home.razor.cs:50uses aloadedflag thoughOnInitializedAsyncalready runs once per instance. Commented-out//var subclasses = subclassRepository.GetSubClassList();inChartService. - Legacy nuget packages. All three library projects reference
System.Text.RegularExpressions 4.3.1,System.Xml.XmlSerializer 4.3.0,System.ComponentModel.Annotations 5.0.0— legacy .NET Standard packages, redundant onnet9.0. Common.csprojhas<Folder Include="Interfaces\Extensions\" />for a folder that does not exist.ClassIdsshould bestatic class; currently instantiable.
3. Testability
- Zero tests, zero test project.
- Business rules (balance calculation, FX conversion, bank-transaction matching, CSV export, split logic) are entangled with mutable singletons and side effects on returned entities.
Phased plan
Phase 1 — Stop the bleeding (correctness, low churn)
ChangeDeferred.ISettingsServiceregistration fromSingletontoScoped.MyHeader.YearChangedusesNavigateTo(..., forceLoad: true)after mutating settings, which tears down the SignalR circuit — a Scoped instance would be recreated with default values on the new circuit. Proper fix: persist the year/mandator selection to a cookie or query string, then Scoped becomes safe. Tracked as a new Phase 2 item.- Fix
TrySetYear(capturethis.yearbefore overwriting). Defer theChartServiceprior-year-loading pattern to Phase 3. - Fix
DummyFxServicetypo (or delete the class and replace with a realIFxServiceimplementation fromSchaad.Finance.Api). - Remove mutation-on-read in
TransactionRepository.GetTransaction,ViewService.GetTransactionViewList(accountId), andFileService.GetTransactionListCsv. - Add a not-found guard to
TransactionRepository.GetTransaction. - De-duplicate the DI registrations. One
AddAccounting()extension called once inProgram.cs. - Replace
.Class == 3/4magic numbers withClassIds.*. Update CLAUDE.md's incorrect ClassIds section. - Make XML writes atomic: write to
foo.xml.tmpthenFile.Move(..., overwrite: true). Wrap Load/Save in a per-fileSemaphoreSlim(or a simplelock).
Phase 2 — Structural cleanup
- Introduce a per-request unit of work / cache. A scoped
IAccountingContextthat loads each XML file at most once per request and holds the deserialized lists. - Replace
Copy(target)methods with a single merge-in-place pattern (orrecord with). - Extract shared formatting to a single
Formattinghelper. - Move
IFileService,IChartService,IViewServiceout ofCommon(they depend onSchaad.Finance.Api). Common should have no vendor dependency. - Fix typos (
EnsureFileExists,SplitPredefinitionRepository). - Move file existence bootstrapping out of constructors into a startup step (
IHostedServiceor lazy first-use). - Fix
AccountRepository.EnsureFileExiststo compute the start-balance updates in memory and save once. - Remove legacy NuGet packages. Remove the stale
Interfaces\Extensions\folder entry. - Set culture via
RequestLocalizationOptionsmiddleware. - Add
ILogger<T>to services and repositories. 18b. Persist selected year and mandator across page reloads (cookie, query string, orProtectedLocalStorage). Prerequisite for makingISettingsServiceScoped(Phase 1 item 1, deferred).
Phase 3 — Async & performance
- Convert repository interfaces to async.
- Cache the current view's data behind the scoped unit-of-work; invalidate on save.
- Precompute
accountsByIdandsubclassNameByNumberdictionaries once per request. - Clean up
ChartServiceprior-year loading pattern: introduce an explicit "load year data" helper instead of mutatingISettingsService.
Phase 4 — Testability & safety net
- Add a
Schaad.Accounting.TestsxUnit project. - First tests:
ViewService.GetBalanceView,MatchBankTransactionByBookingRule/SameAccountsLastMonth,TransactionRepositoryFX round-trip,FileService.GetTransactionListCsv. - Abstract the XML store (
IEntityStore<T>) so tests use an in-memory store.
Phase 5 — Nice-to-haves
- Replace hand-written XML models with
recordtypes + source-generated serializers. - Inject Fixer.io key via
IOptions<FxSettings>instead of threading through method params. - Prune unused Fluent UI packages.
- Add a health/backup admin page.
PR breakdown
- PR A — Phase 1 items 1–7 + CLAUDE.md fix.
- PR B — Phase 1 item 8 (atomic writes + lock).
- PR C — Phase 2 mechanical cleanup: items 11, 13, 15, 16, 17.
- PR D — Phase 2 item 9: per-request unit of work / cache. Split off from PR C because it is invasive enough to warrant its own review.
- PR E — Phase 3 item 22: ChartService cleanup. Promoted ahead of async because it fixes an active correctness bug —
GetAccountExpensesPerMonth(accountId, year)was mutatingISettingsServiceon the singleton to hop years, with the year parameter always hardcoded toDateTime.Now.Year, so the Spendings-over-time chart discarded the user's header year selection. - PR F — Phase 3 item 21: precomputed dictionaries in
ViewServiceto eliminate O(N·M)Single(...)scans and per-account transaction filtering. - Item 19 (async I/O) — landed as PR P after being explicitly requested. See PR P entry below.
- PR G — Phase 4 items 23 + 24:
Schaad.Accounting.TestsxUnit + NSubstitute + Shouldly project. Adopts thexxxTestShould.DoThisWhenThatnaming convention with Shouldly assertions (no xUnitAssert.*). CoversViewService,TransactionRepository,Formatting,RepositoryCache,AccountRepository,ChartService, andFileService.GetTransactionListCsv— 38 tests locking in the earlier PRs' behavior. Note: originally shipped as three separate commits (initial project, expanded coverage, Shouldly + naming conversion) and later squashed into one commit at the user's request. - PR H — Phase 2 item 18 (first slice): add
ILogger<FileService>toFileService.ImportAccountStatementFileso bank-statement imports emitInformationfor the file being processed and each account's import count,Warningwhen an account is skipped because it belongs to a different mandator, andErrorwhen the vendor parser reports a failure. Rest of the logging (BaseRepository save failures, MatchOpenBankTransactions summary) tracked as a follow-up because it requires threading loggers through all seven repositories. - PR I — Phase 2 item 12: move the service interfaces (
IViewService,IFileService,IChartService) fromSchaad.Accounting.CommonintoSchaad.Accounting.Services/Interfaces/. Namespaces are unchanged (Schaad.Accounting.Interfaces), so no consumer needs ausingupdate. Drops theSchaad.Finance.Api<Reference>fromCommon.csproj— Common is now vendor-free and matches its documented role as the "shared models, DTOs, interfaces" layer. - PR J — Phase 2 item 18 (second slice): thread
ILogger<T>throughBaseRepositoryand every concrete repository soBaseRepository.Save's catch block logs the failing file path and exception before rethrowing (previously the exception's origin was silently swallowed and only the stack trace at the callsite survived). PullsMicrosoft.Extensions.Logging.Abstractionsinto the Db project.TransactionRepositoryTestShouldandAccountRepositoryTestShoulduseNullLogger<T>.Instance. Remaining item 18 work (MatchOpenBankTransactions match-count summary inViewService) still open. - PR K — Phase 5 item 27: introduce
IFxConverter.ConvertToChf(amount, fromCurrency)and itsFxConverterimplementation. The vendorIFxServiceand the FixerIo API key are now hidden insideFxConverter; callers stop threading the API key through every method call.ViewServicedrops bothIFxServiceandISettingsServicefrom its constructor and takesIFxConverterinstead. SimplifiesGetAccountViewList(no more per-callsettingsService.GetSettings()reads) andGetBalanceSheetView. Two-testFxConverterTestShouldlocks in the target-currency and API-key routing. - PR L — Phase 2 item 10: drop the hand-rolled
Copy(target)methods fromAccount,BookingRule,BookingText,SubClass, andSplitPredefinition; keep only aTransaction.Clone()for defensive-copy needs inGetTransaction/ViewService.WithDisplaySign. EverySaveXxxnow usesFindIndex→ in-place replace (orAddfor new entries), moving the two "hidden" defaults (Account.Currency = "CHF"when unset,Transaction.BookingDate = ValueDatewhen unset) into the correspondingSaveXxxmethod where they belong. Fixes a pre-existing bug:Transaction.Copynever copiedRelatedParty, so update-saves silently dropped it. AddedPreserveRelatedPartyWhenRoundTrippingTransactionandDefaultBookingDateToValueDateWhenBookingDateIsUnsetas regression tests. 42 tests total. - PR M — Phase 2 item 18 (final slice): add
ILogger<ViewService>and log a match-count summary fromMatchOpenBankTransactions("Matched {Matched} of {Total} open bank transactions"). Completes item 18 — statement imports (PR H), repository save failures (PR J), and match runs now all emit structured logs. Also removes a deadISettingsServiceinjection fromHome.razor.cs(declared with[Inject]but never used anywhere in the file). - PR N — Small polish pass covering three long-noted rough edges from the original analysis:
ClassIdsbecomes astatic class(was instantiable);Home.razor.csloses the redundantloadedguard inOnInitializedAsync(Blazor already runs that lifecycle hook once per component instance); the hardcodedmandator = "Claudio Schaad"inSettingsServicemoves to aDefaultMandatorfield onSettingsDataset, plumbed throughappsettings.Development.json. - PR O — Phase 5 item 26 (partial): convert
BalanceDatasetandBalanceSheetDatasetto positional records. Both were constructor-initialised value carriers with read-only usage. The rest of item 26 was descoped: the XML-serialised domain models (Account,Transaction, ...) need mutable public setters forXmlSerializer, and value-equality on mutable data is a footgun (hash changes on mutation); source-generated XML serialisers do not exist without switching file formats.DataSeriewas already a record;MessageDatasethas a real mutation method (Add) and stays a class;AccountDataset/TransactionDataset/BookingRuleDatasetinherit from the mutable domain models and can't cleanly become records without a bigger refactor. - PR P — Phase 3 item 19: full async I/O top to bottom.
BaseRepository.LoadAsync/SaveAsync/LoadListAsyncreturnTask<T>; the per-filelock (obj)becomesSemaphoreSlimso it can beawait-ed.Saveserialises to aMemoryStreamsynchronously (XmlSerializer has no async form) then writes bytes withFile.WriteAllBytesAsync;File.Move(atomic rename) has no async counterpart in .NET 9 and stays sync. Every repository interface + implementation, every service interface + implementation (except the vendor-ownedIFxService), every Razor page/dialogOnInitializedAsync, and every test-file assertion becomesasync/await. 51 files touched in one atomic diff — the codebase does not compile in intermediate states.AccountRepository.EnsureAccountsFilekeeps two.GetAwaiter().GetResult()bridges because it runs from the constructor (constructors can't be async). The observed async payoff is oneFile.ReadAllBytesAsyncand oneFile.WriteAllBytesAsyncper Load/Save; after PR D each XML file is deserialised at most once per SignalR circuit, so on this single-user local Blazor Server app this is architectural-hygiene work, not a measurable perf win.