.NET ONION API Scaffolder
Generate a production-grade .NET solution that keeps the good layered patterns (Api → Application → Infrastructure → Domain separation, base classes for cross-cutting concerns, extension-method wiring in Program.cs, JWT, AutoMapper, auto-DI, unified error responses) and eliminates the bad ones often seen in legacy .NET codebases (stored-procedure-centric reflection repositories, EF6 on netstandard2.1, polling console-app workers, swallowed exceptions, mutable per-request state on base service classes, mixed ADO/Dapper/EF6 data access, missing CancellationToken plumbing).
When to use this skill
Trigger on any of:
- "create a new dotnet project / .NET solution / C# project"
- "scaffold a .NET Web API"
- "new microservice in .NET" / "add a worker service"
- "use the patterns I like" / "use my layered .NET conventions" (in a .NET context)
- "ONION architecture" / "Clean Architecture" / "layered .NET project"
- "add a feature end-to-end" / "add a slice" (API + service + repository + EF entity + tests)
- The user pastes a feature spec and asks you to wire it through the layers of a .NET solution
If unsure whether the user wants a brand-new solution vs. an addition to an existing one, ask once — don't guess.
Three operating modes
Pick the mode from the user's request. If ambiguous, ask.
| Mode | Trigger | Output |
|---|---|---|
scaffold-solution |
"new project", "scaffold solution", empty directory | Full ONION solution: Domain, Application, Infrastructure, Api, Workers (optional), Tests. |
add-feature |
"add <Entity> end-to-end", "wire up <feature> through all layers" |
Entity + EF config + repository (port + adapter) + use-case service + DTO + controller + AutoMapper profile + unit test. |
add-worker |
"new worker", "add microservice for queue X" | New Workers.<Name> project (BackgroundService) referencing Application + Infrastructure, with queue/service-bus consumer and graceful shutdown. |
Workflow
Step 1 — Determine the target framework (don't hard-code)
Never bake a hard-coded <TargetFramework> into generated projects — resolve it at scaffold time. Before generating .csproj files:
- Check the user's environment first: run
dotnet --list-sdksto see installed SDKs. - If a current LTS SDK is installed, prefer the highest installed LTS (
net8.0,net10.0, etc.). - If unsure which is the current LTS, fetch the latest .NET support policy via context7 (
mcp__plugin_context7_context7__resolve-library-id→query-docsfor ".NET release schedule" / "dotnet support policy") or WebFetchhttps://dotnet.microsoft.com/en-us/platform/support/policy/dotnet-core. Quote the version you picked back to the user before generating. - Pin EF Core, ASP.NET Core, and
Microsoft.Extensions.*package versions to the latest stable for that TFM — look them up via context7 (Microsoft.EntityFrameworkCore,Microsoft.AspNetCore.Authentication.JwtBearer, etc.) rather than guessing. Never hand-paste a version you don't have a source for. - State the chosen TFM and package versions in your reply before writing files, so the user can object before scaffolding.
- Concrete resolution commands when context7 is unavailable:
dotnet package search <PackageId> --take 1or WebFetchhttps://api.nuget.org/v3-flatcontainer/<package-id-lowercase>/index.json(last non-preview entry = latest stable). Never write a version string you did not just resolve this session — no versions from memory, no wildcards, no invented numbers.
API-drift guard. The version you resolved in this step decides the API shape — training-data memory is the least trustworthy source in this workflow. Highest-risk hallucination zones:
- EF Core APIs across majors (e.g.
HasCheckConstraintmoved intoToTable(t => t.HasCheckConstraint(...))in EF7; query/interceptor APIs get renamed between majors). - Hosted-service and
Hostbuilder idioms —Host.CreateApplicationBuilder(post-.NET 7) vs the olderCreateDefaultBuildercallback style; don't mix the two. - TFM defaults: newer TFMs flip
<Nullable>/<ImplicitUsings>behavior — templates must match the resolved TFM, not a remembered one. - OpenAPI/Swagger: .NET 9+ templates dropped Swashbuckle for built-in
Microsoft.AspNetCore.OpenApi(AddOpenApi()/MapOpenApi()). Pick the approach matching the resolved TFM; never wire both. - Test stack majors: xunit v3 ships as the
xunit.v3package with different runner wiring thanxunit2.x; AutoMapper 13+ self-registers (no.Extensions.Microsoft.DependencyInjectionpackage). Match templates to what you resolved, not to package names from memory.
If you are not certain a symbol exists in the resolved version, verify it (context7 docs lookup, or read the restored package surface under ~/.nuget/packages/) before writing code that depends on it.
Step 2 — Gather inputs (ask once, in one batch)
For scaffold-solution, use AskUserQuestion to collect:
- Solution name (e.g.
Acme.Billing). Used for namespace root and.sln. - First feature/entity (optional, e.g.
Customer) — if provided, also runadd-featurefor it after scaffold. - Auth: JWT bearer with HS256 (symmetric) or JWT with asymmetric (RS256/JWKS) or none-yet.
- Include any Workers now? If yes, ask for their names (comma-separated, e.g.
EmailSender, PdfPrinter). - Optional extras: Serilog + OpenTelemetry? Dockerfile? GitHub Actions CI? Testcontainers integration tests? (Default: skip — only add if asked.)
For add-feature: entity name, properties (name + C# type + nullability), whether it needs CRUD controller or only specific endpoints.
For add-worker: worker name, trigger source (Azure Storage Queue / Service Bus / Timer), message DTO type (if known).
Step 3 — Generate files
Use the templates in references/templates/ as the source of truth for file contents. Apply these rules:
- Use
Writefor new files. Never useEditon files you're creating fresh. - Replace all
{{Solution}},{{Feature}},{{Worker}}placeholders consistently. - Create directories before files. On Windows shell use PowerShell
New-Item -ItemType Directory -Force. - After scaffolding, run
dotnet sln addfor every project anddotnet buildto verify the solution compiles. Report build output to the user. - Do not run
dotnet newto create the projects — write the.csprojand.csfiles directly from templates so the layout matches exactly. - If a
dotnet newtemplate IS run anyway (user's insistence): the SDK wins on formats it owns (.slncontents,obj//bin/artifacts), this skill wins on everything else — overwrite template boilerplate with the skill's templates, keep this skill's project split and folder names. Reconcile deliberately and list every deviation in your report; never silently abandon the skill's patterns, never fight the SDK on formats it owns.
Step 4 — Verify and report
- Run
dotnet buildfrom the solution root. If it fails, fix the first error (later errors are usually cascade) and rebuild — never leave a broken scaffold. - Run
dotnet testif any test project exists. - Do not run
dotnet ef migrations addordotnet ef database updateyourself unless the user confirmed a reachable database — report the migration command as a next step instead. The delivery bar is the build gate, not the migration. - Known failure signature:
NETSDK1045("The current .NET SDK does not support targeting …") means the TFM you picked outruns the installed SDK — re-rundotnet --list-sdksand re-pin to the highest installed LTS; do not install a new SDK unprompted. - A scaffold that hasn't compiled is not delivered. Paste the actual proof lines in your report (
Build succeeded./0 Error(s), and thePassed! - Failed: 0test summary). Never report success from memory of the steps you intended, and never report partial success as success — if something is red, say exactly what is red. - CLI failure protocol (applies to
dotnet new,dotnet sln add,dotnet build,dotnet test): read the full error output, change exactly one thing, retry once. If the same step fails twice, stop scaffolding and surface the verbatim error to the user — do not keep generating files on top of a broken base, and do not re-run the identical command hoping for a different result. - Reply with a short summary: solution path, projects created, TFM chosen, package versions, next steps (e.g. "run
dotnet ef migrations add Initial -p src/{{Solution}}.Infrastructure -s src/{{Solution}}.Api").
Solution layout (canonical)
See references/solution-layout.md for the full tree and dependency rules.
{{Solution}}/
{{Solution}}.sln
src/
{{Solution}}.Domain # entities, value objects, domain events. NO project refs.
{{Solution}}.Application # use-case services + ports (interfaces) + DTOs + validators. refs Domain.
{{Solution}}.Infrastructure # EF Core DbContext, repository adapters, external clients (Azure, email, auth). refs Application+Domain.
{{Solution}}.Api # Controllers, Middleware, Filters, Program.cs. refs Application+Infrastructure.
{{Solution}}.Workers.<Name> # BackgroundService microservices. refs Application+Infrastructure.
{{Solution}}.Contracts # (optional) public DTOs / API contracts shared with clients.
tests/
{{Solution}}.UnitTests # xUnit + NSubstitute. Tests Application use-cases with mocked ports.
{{Solution}}.IntegrationTests# WebApplicationFactory + Testcontainers (SQL Server). Real DB + real pipeline.
Dependency rule (enforced): outer → inner only. Domain has zero project references. Application references Domain only. Infrastructure may reference both. Api/Workers reference Application + Infrastructure but never each other.
Required code patterns
Use these exact patterns when generating files. Full templates are in references/templates/.
Keep
BaseControllerwith[ApiController],[Route("api/[controller]")],[Authorize], injectedIUserContext— seereferences/templates/base-controller.cs.md.- Base use-case service with constructor-injected
IUserContext(no public mutable user property — a common bug in legacy bases). - Thin
Program.csthat calls only extension methods (AddApplication,AddInfrastructure,AddApiServices,AddJwtAuth,AddSwaggerDocs,AddCorsPolicies). Seereferences/templates/program-cs.md. - Auto-registration via
Scrutorfor ports → adapters (replacesNetCore.AutoRegisterDi, modern + maintained). Singletons/options registered explicitly. - Strongly-typed
AppSettings+ConnectionStringsbound viaIOptions<T>(don't register the raw POCO as singleton — useservices.Configure<T>(...)and injectIOptions<T>). - AutoMapper with assembly scan:
services.AddAutoMapper(typeof(ApplicationAssemblyMarker).Assembly). - Centralized exception middleware with env-aware response — see
references/templates/exception-middleware.cs.md. - Unified validation error response via
InvalidModelStateResponseFactory. - JWT auth wiring in a
RegisterAuthextension method. - Minimal comments in generated code. Default to no comments. Only add one when the why is non-obvious — a workaround for a specific framework bug (with a link), a subtle invariant the code depends on, a domain rule that isn't visible from the names. Never write XML doc-comment blocks (
/// <summary>...) on internal members; reserve them for genuinely public API surface that ships to consumers. Never restate what the next line does, never leave// TODOwithout an issue link. One short line max — no multi-line comment blocks. Well-named identifiers carry the what; comments earn their place only when they carry why.
Eliminate (anti-patterns)
Every one of these is forbidden in generated code. See references/anti-patterns.md for the rationale of each.
- ❌ Stored-procedure-first data access. Use EF Core with LINQ; only drop to raw SQL via
FromSqlInterpolated/ExecuteSqlInterpolatedfor legitimate perf/legacy reasons, and never with reflection-based parameter mapping. - ❌
dynamic/ExpandoObjectfor query parameters. - ❌ Reflection-based
DataRow → objectmappers. EF Core handles this. - ❌ EF6 +
netstandard2.1. Use EF Core (latest) on the chosen TFM. - ❌ Empty
catch {}blocks. Either handle the exception meaningfully or let it propagate to the middleware. - ❌ Mutable
public User { get; set; }on a service base class — request-scoped state belongs in the scopedIUserContextonly. - ❌ Polling
while (true) { Task.Delay(5s) }console-app workers. UseBackgroundServicewithCancellationToken stoppingTokenand SDK-native receive loops — seereferences/templates/worker-program.cs.md. - ❌ Booting hosted services with
serviceProvider.GetService<T>()inProgram.cs. Register them viaservices.AddHostedService<T>(). - ❌ Auto-registering everything as
Scopedindiscriminately. Use Scrutor's lifetime selectors, and register Azure SDK clients /IHttpClientFactoryclients / options as singletons explicitly. - ❌ Newtonsoft.Json +
DefaultContractResolver(PascalCase). Use System.Text.Json withJsonNamingPolicy.CamelCaseby default. Add Newtonsoft only if a specific dependency demands it. - ❌ Missing
CancellationTokenparameters. Every async public method takesCancellationToken ctas the last parameter and forwards it. - ❌ Per-tenant repositories under
Repositories/{TenantName}/. Multi-tenant behavior goes through a strategy injected via DI, not folder forks. - ❌ Hard-coded multi-tenant magic fallbacks (e.g. defaulting
ClientIdto a literal string when claims are missing). Multi-tenancy comes fromIUserContextor fails fast. - ❌ Commented-out dead code in generated files.
- ❌ Controllers that bypass
BaseController(consistency is mandatory; if a public endpoint needs[AllowAnonymous], declare it on the action).
Operating-mode playbooks
Mode 1 — scaffold-solution
- Pick TFM and package versions per Step 1. Quote them.
- Ask the inputs per Step 2. Wait for answers.
- Generate, in this order:
-
.slnfile (usedotnet new sln -n {{Solution}}only to produce the sln; everything else is hand-written from templates). -
src/{{Solution}}.Domain/(csproj +DomainAssemblyMarker.cs+ sampleEntitybase if relevant). -
src/{{Solution}}.Application/(csproj + assembly marker +Common/withIUserContext,Result<T>if requested,IUnitOfWorkport,IRepository<T>port). -
src/{{Solution}}.Infrastructure/(csproj +Persistence/AppDbContext.cs+Persistence/EntityConfigurations/folder +Persistence/UnitOfWork.cs+Auth/UserContext.cs+DependencyInjection.cswithAddInfrastructure). -
src/{{Solution}}.Api/(csproj +Program.cs+Extensions/folder +Middlewares/ExceptionHandlerMiddleware.cs+Controllers/BaseController.cs+appsettings.json/appsettings.Development.json).Checkpoint:
dotnet sln additems 1–5 anddotnet buildnow, before generating workers and tests — a failure here localizes to the core projects; a failure after the full tree does not. Apply the Step-4 failure protocol at this checkpoint too. -
src/{{Solution}}.Workers.<Name>/per worker requested. -
tests/{{Solution}}.UnitTests/(csproj + xUnit + NSubstitute + AutoFixture). -
tests/{{Solution}}.IntegrationTests/(csproj +Microsoft.AspNetCore.Mvc.Testing+Testcontainers.MsSql) — only if user asked for it.
-
dotnet sln {{Solution}}.sln addevery project (one command, all projects).dotnet build— must succeed.- If a first feature was requested, immediately run Mode 2 for that entity.
- Report.
Mode 2 — add-feature
For entity {{Feature}} (e.g. Customer):
- Domain:
src/{{Solution}}.Domain/{{Feature}}s/{{Feature}}.cs— POCO entity with a private parameterless ctor for EF, a public ctor for invariants, and behavior methods (avoid anemic models). Add domain events only if asked. - Application:
- Port:
src/{{Solution}}.Application/{{Feature}}s/I{{Feature}}Repository.cs(interface with CRUD methods that takeCancellationToken). - DTOs:
Application/{{Feature}}s/Dtos/{{Feature}}Dto.cs,Create{{Feature}}Request.cs,Update{{Feature}}Request.cs. - Use-case service:
Application/{{Feature}}s/{{Feature}}Service.cs+ interfaceI{{Feature}}Service.cs. Service depends onI{{Feature}}Repository,IUnitOfWork,IMapper. Pure orchestration — no EF references. - AutoMapper profile:
Application/{{Feature}}s/Mapping/{{Feature}}Profile.cs. - FluentValidation validator (only if user asked for FluentValidation; otherwise rely on DataAnnotations + the validation factory).
- Port:
- Infrastructure:
- EF configuration:
Infrastructure/Persistence/EntityConfigurations/{{Feature}}Configuration.cs(implementsIEntityTypeConfiguration<{{Feature}}>). - Repository adapter:
Infrastructure/Persistence/Repositories/{{Feature}}Repository.cs(implementsI{{Feature}}RepositoryusingAppDbContext). - Register the DbSet on
AppDbContext.
- EF configuration:
- Api:
- Controller:
Api/Controllers/{{Feature}}sController.csinheritingBaseController, injectingI{{Feature}}Service. Standard REST endpoints, returning DTOs only.
- Controller:
- Tests:
tests/{{Solution}}.UnitTests/{{Feature}}s/{{Feature}}ServiceTests.cs— xUnit + NSubstitute, covers the service's happy path + one validation/edge case.
Template for the full slice is in references/templates/feature-slice.md.
After generating: dotnet build then dotnet test. Both must pass — apply the Step-4 proof-and-failure protocol (paste the green lines; the same step failing twice = stop and surface the verbatim error).
Mode 3 — add-worker
For worker {{Worker}} (e.g. EmailSender):
- Create
src/{{Solution}}.Workers.{{Worker}}/:csprojperreferences/templates/worker-csproj.md.Program.csusingHost.CreateApplicationBuilderperreferences/templates/worker-program.cs.md.Worker.cs—BackgroundServicesubclass; loop driven bystoppingToken; respects graceful shutdown.appsettings.json+appsettings.Development.json.
- Reference
Application+Infrastructure(neverApi). - Add to
.sln.dotnet build. - No
while (true) { ... await Task.Delay(5s) }— use the SDK's receive loop (e.g.await foreach (var msg in receiver.ReceiveMessagesAsync(stoppingToken))for Service Bus, orawait queueClient.ReceiveMessagesAsync(maxMessages, ct: stoppingToken)inside awhile (!stoppingToken.IsCancellationRequested)loop).
NuGet packages (resolve latest stable at scaffold time)
Look these up via context7 — do not hand-paste versions:
Api project
Microsoft.AspNetCore.Authentication.JwtBearerMicrosoft.AspNetCore.OpenApiSwashbuckle.AspNetCoreAutoMapper.Extensions.Microsoft.DependencyInjection(orAutoMapper13+ which self-registers)Scrutor(assembly-scanning DI)Serilog.AspNetCore+Serilog.Sinks.Console(only if user opted into Serilog)
Application project
MediatRonly if user explicitly asks for CQRS; default is plain service classesFluentValidationonly if requested
Infrastructure project
Microsoft.EntityFrameworkCore.SqlServerMicrosoft.EntityFrameworkCore.Design(PrivateAssets="all")Microsoft.EntityFrameworkCore.Tools(PrivateAssets="all")Microsoft.Data.SqlClientAzure.Storage.Blobs,Azure.Storage.Queues,Azure.Messaging.ServiceBus(only if used)
Worker projects
Microsoft.Extensions.Hosting- Same Azure SDK packages as Infrastructure (only what the worker actually uses)
Test projects
Microsoft.NET.Test.Sdkxunit,xunit.runner.visualstudioNSubstitute(preferred over Moq — cleaner API, actively maintained)FluentAssertionsTestcontainers.MsSql(integration tests only)Microsoft.AspNetCore.Mvc.Testing(integration tests only)
Final self-audit (mechanical — grep, don't recall)
The Eliminate list is easy to hold at file 1 and forgotten by file 30. After generating and before the final build, grep the generated tree — every command must return nothing:
grep -rn "Thread.Sleep\|while (true)" src/ # polling-loop workers
grep -rnE "catch\s*(\(\s*Exception[^)]*\))?\s*\{\s*\}" src/ # swallowed exceptions
grep -rn "ExpandoObject\|DataRow\|SqlDataAdapter" src/ # sproc-era / reflection data access
grep -rn "GetService<" src/*/Program.cs # hosted services booted by hand
grep -rn "Newtonsoft" src/ # unless a dependency demanded it
grep -rln "public async Task" src/ | xargs grep -Ln "CancellationToken" # async surface with no ct anywhere
grep -rn "System.Data.Entity\|\"EntityFramework\"" src/ # EF6 leaking into an EF Core solution
grep -rn "CreateDefaultBuilder" src/ # mixed host-builder idioms
grep -rn "/// <summary>" src/ # XML doc blocks on internal members
A hit means fix it and re-run the grep — never rationalize it away or report it as acceptable.
Verification checklist before reporting "done"
-
dotnet buildexits 0. -
dotnet testexits 0 (if tests were generated). - No
.csprojreferences violate the ONION rule. Concrete check:grep "ProjectReference" src/*/*.csproj— Domain shows zero hits, Application references only Domain, Api/Workers never reference each other. - No file contains any pattern from the Eliminate list.
-
Program.csis under ~50 lines (everything else is in extension methods). - Domain project's
.csprojhas zero<ProjectReference>entries.
If any check fails, fix before reporting. Don't claim success with a known-broken scaffold.
Examples
Example 1: Fresh solution
User: "Scaffold a new dotnet API project called Acme.Billing using my ONION patterns. Add a Customer feature too."
Claude:
- Runs
dotnet --list-sdks, checks context7 for current LTS, picks (e.g.)net8.0. - Reports chosen TFM + package versions; waits for confirmation if anything looks off.
- Asks the Step-2 questions (auth flavor, workers, extras).
- Generates the full solution + the
Customerfeature slice. - Runs
dotnet buildanddotnet test. - Reports the tree and the EF migration command.
Example 2: Add a feature
User: "Add an Invoice feature end-to-end to the existing solution."
Claude: Runs Mode 2 only — generates Domain entity, Application port + service + DTOs, Infrastructure config + repository, Api controller, unit test. Builds and tests.
Example 3: Add a worker
User: "Add a worker that processes the print-jobs Azure Storage queue."
Claude: Runs Mode 3 — generates Workers.PrintJobs project with a BackgroundService that receives messages with stoppingToken, calls into an Application service, deletes on success. Wires it into the .sln. Builds.
Notes
- Don't over-engineer: don't add MediatR, CQRS, MassTransit, Polly, MinimalAPI conversions, Result patterns, or domain events unless the user asks. Pragmatic > pure.
- Don't rewrite the user's existing codebase as part of this skill. This skill is for new scaffolds, not migrations. If the user wants a migration plan, that's a different conversation.
- Multi-tenancy: if the new project needs it, generate a
TenantContextmirroringIUserContextand use EF Core query filters (HasQueryFilter) rather than per-tenant repositories. - Always quote the TFM and package versions you chose before writing files — they are resolved at scaffold time, never hard-coded.
