Information at endpoint and service-method boundaries, Debug in deeper helpers, Warning before expected/recoverable failures (not-found, validation, agent tool errors), Error on caught exceptions. Serilog wraps the exception handler so request-completion logs report the resolved status code rather than the raw exception. Never logs prose bodies or the Anthropic API key.
7.5 KiB
CLAUDE.md
Guidance for Claude Code (claude.ai/code) in this repo.
Project
Novelly: software for planning and writing a novel. ASP.NET Core 10, C#, TypeScript, React, .NET Aspire. Chapter outlines, character dossiers, prose drafting, an embedded Claude agent, and an MCP server over the same API.
Structure
src/Novelly.Api/— the whole back end, organised by feature. One folder per feature holds its entity, DTOs, service and endpoints together:Projects/,Characters/,Chapters/,Beats/,Scenes/,Tags/,Agent/.Common/holds what genuinely crosses features;Data/holds theDbContextand EF migrations.src/Novelly.AppHost/— .NET Aspire orchestration; run this to bring up the API and the web clientsrc/Novelly.ServiceDefaults/— shared Aspire wiring: OpenTelemetry, health checks, service discoverysrc/Novelly.Mcp/— MCP stdio serversrc/Novelly.Web/— React + Vite clienttests/— test suitedocs/— documentationscripts/ci/— bash CI steps;prepush.shis what the Husky pre-push hook runs
Best Practices
- Use latest .NET + latest supported nuget packages for that version
- Set
langVersionto latest in all csproj files; enable nullable - Organize code by feature/area, not layer or type. A new capability adds files to one feature folder rather than a row to each of an entity/DTO/service/endpoint folder
- New features need unit tests covering logic as much as possible
- Modified file: check missing test coverage, all tests pass
Architecture
- The React client, the embedded agent, and the MCP server all go through the same REST API. One source of truth — never let a client reach past the API to the database.
- Agent tools and MCP tools call the same application services the endpoints do. New capability = new service method, then surface it in all three.
- Domain has no dependencies. Application depends on Domain. Infrastructure depends on Application. Nothing depends on Api.
Logging
Logging goes through Serilog, writing to the console, registered with AddSerilog rather than UseSerilog — this makes it an additional logging provider alongside the OpenTelemetry one that AddServiceDefaults wires up, instead of replacing it, so the Aspire dashboard keeps seeing structured logs. app.UseSerilogRequestLogging() must be registered before app.UseExceptionHandler(...) so it wraps the exception handler as the outermost middleware; otherwise it observes the raw exception as it propagates past it and logs the request as a 500 even when the exception handler goes on to turn it into a handled 404.
- Endpoints get their Information-level logging from a single
RequestLoggingEndpointFilter, applied once perMapGroupcall. No individual endpoint lambda needs to log anything itself. - Every public service method logs an Information entry naming the operation and its identifying arguments — ids, enums, counts.
- Deeper, private helper methods log at Debug on entry and again before they return, including whatever state drove the decision.
- Anywhere an exception is caught, log it at Error with
LogError(ex, ...), including enough identifying values (ids, not prose) to debug from. - Expected or recoverable failures — a not-found lookup, a validation rejection, an agent tool call that fails and is reported back to the model instead of thrown — log at Warning immediately before the throw or the recovery, not at Error.
- Always use structured message templates with named holes, like
{ChapterId}, rather than string interpolation, so Serilog captures them as properties. - Never log prose bodies (summary, notes, scene prose, agent message content) or the
ANTHROPIC_API_KEY. Where a prose field is worth acknowledging in a log, log its length instead of its content.
Coding
- Descriptive names all classes/methods. No generic: Provider, Manager, Helper
- Match formatting/style from
.editorconfig - Wrap lines at 220 chars, single line if fewer
- Interfaces implemented by single class → bottom of class file. Interface w/ multiple implementations → separate file.
- No tuples for return types. Prefer records or classes for multiple values
- Use
recordfor data objects,classfor objects with behavior. Avoid mutable state where possible. - DTOs are records; entities are classes
PATCHrequests are partial: null field = leave alone, empty string = clear. Keep new update endpoints consistent withPatch.Apply.- Enums cross the wire as names, never ordinals
Testing
- Min 70% code coverage, target 90%. Unit tests focus end-user scenarios first.
- Don't write tests just for coverage. Call out missing coverage rather than cover stuff not valuable to end user.
- Code not cleanly unit-testable → mark
[ExcludeFromCodeCoverage]or exclude namespace from coverage in .runsettings file - BDD-style unit tests, end-to-end as possible. e.g.
Deleting_a_scene_leaves_its_beats_alone - NUnit. No FluentAssertions — assert with
Assert.Thatand NUnit's constraint model:Assert.That(beat.SceneId, Is.Null),Assert.That(listed, Has.Count.EqualTo(3)),Assert.That(titles, Is.EqualTo(new[] { "First", "Second" })) - Grouping related asserts in
Assert.Multiplebeats a chain that stops at the first failure - Expected exceptions:
Assert.That(() => service.Foo(), Throws.TypeOf<NotFoundException>()) - Tests run against real in-memory SQLite via
TestDatabase, not the EF InMemory provider — cascade deletes and query translation must be exercised, and the InMemory provider fakes both - Model calls are faked at the
IAgentModelClientseam (seeScriptedModelClient). Never hit the Anthropic API from a test. - No "Mock" in mocked object names
- No Arrange/Act/Assert comments
- All tests pass before commit
Verifying
Build and tests passing is not the same as working. For anything touching an endpoint, the agent loop, or the MCP server, run it:
- Everything at once:
dotnet run --project src/Novelly.AppHost— Aspire starts the API on :5080 and the Vite dev server on :5173, with the dashboard for logs and traces - API alone:
ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/Novelly.Api, then exercise the route with curl - Web alone:
cd src/Novelly.Web && npm run dev— proxies/apito :5080 - MCP: build it, then drive it over stdio JSON-RPC (
initialize→notifications/initialized→tools/list→tools/call)
Several real bugs here — SQLite refusing to ORDER BY a DateTimeOffset, the agent's model client throwing at construction and taking read-only endpoints down with it — passed the build and the test suite and only showed up when the app actually ran.
Claude
- Plans =
.mdfiles indocs/plans/. Web →docs/plans/web/, API →docs/plans/api/ - Split large plans into discrete chunks — each buildable + committable independently
- Plan generated from
docs/plans/<name>.md→ save asdocs/plans/<name>_plan.md - Plan implemented from
docs/plans/<name>.md→ save summary asdocs/plans/<name>_output.md
Stack
.gitignore set for .NET/Visual Studio (C#, NuGet, MSBuild) plus Node. Update if stack change.
- Anthropic model id lives in
appsettings.jsonunderAgent:Model. Don't hardcode it. - API key comes from
ANTHROPIC_API_KEYorAgent:ApiKey— never commit one. The app must stay fully usable without a key; only the agent endpoints require it. - EF migrations:
dotnet ef migrations add <Name> -p src/Novelly.Api -o Data/Migrations. The API migrates on boot. git pushrunsscripts/ci/prepush.shthrough Husky: build, test, then a web build. Runnpm installonce at the repo root to install the hook.