Skip to content

Offer the repository layout, with tooling to create and maintain it - #2

Merged
KRSogaard merged 20 commits into
masterfrom
repository-layout
Sep 7, 2026
Merged

KRSogaard merged 20 commits into
masterfrom
repository-layout

Conversation

@KRSogaard

@KRSogaard KRSogaard commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Offers the repository layout MagicDoor runs its backend on as something other teams can adopt, with the
tooling to create and maintain it. Entirely optional — nothing in the eleven packages reads
magiccsharp.json or cares where a file sits, and a consumer using the packages in their own structure is
unaffected by all of this.

Follows on from #1, which brought the framework improvements across and split the packages.

The gap this closes

#1 shipped AddEntity, SyncAllProjects and ValidateConventions, and documented the Apps/ + Libs/
layout — but nothing could create one. AddEntity required projects you had to hand-build first, and the
only way to get the tools was to clone this repository and copy a directory out of it.

Three ways in

# 1. Install the tool
dotnet tool install -g MagicCSharp.Cli
mcs init --prefix Acme
mcs create-app --name Shop --database shop

# 2. Pin it for a team — scaffolds a tool manifest everyone restores
dotnet new install MagicCSharp.Templates
dotnet new magiccsharp-repo -n Acme && cd Acme && dotnet tool restore

# 3. Existing repository — writes only what is missing
mcs init --prefix Acme

What's here

Four new generators — InitRepo, CreateApp, CreateAppLib, CreateLib — completing the set so a
repository, service, domain and shared library can all be created rather than reproduced by hand.

MagicCSharp.Cli, a .NET global tool providing mcs. Started life as eight single-file dotnet run
scripts; they worked, but shared code had to be copied between them — TemplateResolver in six files,
~860 of 2,593 lines duplicated — and none of it was testable. One project instead, with 50 tests where
there were none, and dotnet tool update replacing a hand-written installer and update check.

MagicCSharp.Templates, a dotnet new template pack. The scripts are copied from tools/ by an MSBuild
target at pack time rather than duplicated, so there is one source of truth.

Team-owned templates. Everything the generators write comes from a .hbs template, and a team can
replace any single one without forking the rest. Two layers, first match winning: .magiccsharp/templates/
in the repository, then the built-ins embedded in mcs. Configured by the templates key in
magiccsharp.json; "" turns overrides off.

mcs templates list                        # every template, and which layer provides it
mcs templates eject Entities/dal.cs.hbs   # copy one in to customise

Resolution is per file, so an override taken today does not stop you receiving improvements to every
template you did not touch.

Documentation. docs/repository-layout.md covers setup, what each boundary is for, what belongs in
Libs/ and what does not, overriding templates, and adopting this in a repository that already exists.
tools/README.md gives every tool its real options and a worked example.

Review notes

Three bugs here were only findable by running the thing end to end, and are the parts most worth a look:

  • The generated Directory.Build.props applied to the tools themselves, so a repo targeting net9.0 produced
    scripts the .NET 10 runtime refused to launch. tools/Directory.Build.props now pins them and does not
    inherit.
  • MSBuild infers a culture from the second-to-last extension, so entity.cs.hbs reads as culture cs —
    Czech — and the eight *.cs.hbs templates were routed into a cs/ satellite assembly. The build reported
    all 19 embedded resources; only 11 reached the DLL, and every generator that writes C# silently produced
    nothing. WithCulture="false" fixes it, and a test now asserts all 19 are present.
  • Two earlier bash bugs in the installer that dotnet tool made moot: set -euo pipefail killing the
    dispatcher on a missing state file, and a PATH line hardcoded away from the real install location. Both
    are gone with install.sh.

Two deliberate calls worth confirming:

  • Generated repositories target net10.0, while the libraries stay net9.0 so net9 applications can still
    consume them. A new repository should not start a version behind.
  • The update check compares commit SHAs, not package versions, because the tools install from a git ref
    and scripts/version.txt moves on the NuGet release schedule.

Verified

From an empty directory, running only the documented commands: init → create-app → create-domain →
create-lib → add-entity. The result builds against the packed packages, passes ValidateConventions, and
a generated service serves GET /hello with an X-Request-ID header confirming the middleware is wired.
Repeated through all three entry points, including with no local tools/ directory.

Template overrides verified by ejecting one, editing it, and confirming the generated entity carries the edit
while a non-overridden template still produces built-in output. The installer's PATH handling covered for
zsh, bash, an unrecognised shell, a re-run, a custom MAGICCSHARP_HOME, and NO_MODIFY_PATH. Every tool is
idempotent — second runs produce no diff.

Framework unchanged: build clean, 47 tests passing, linter clean across 114 files, all 12 packages pack.

Not in this PR

AddEvent, AddLib and GenerateAssemblyCatalog are still unported. The last matters once a service spans
many projects — .NET loads an assembly only when one of its types is first touched, so use cases in a project
the host never references go unregistered.

Separately, and not addressed here: scripts/version.txt says 0.0.11 while nuget.org has 0.0.12 and
0.0.13 published.

The tools documented the Apps/ and Libs/ layout but there was no way to
create one — AddEntity assumed projects that nothing could scaffold. This
adds the missing half, so the structure is something you can adopt rather
than something you have to reproduce by hand.

It stays optional. Nothing in the packages reads magiccsharp.json or cares
where a file sits; a consumer using the packages in their own structure is
unaffected.

New tools
- InitRepo sets a repository up: magiccsharp.json, Directory.Build.props,
  central package management, the all-projects solution, Apps/ and Libs/.
  Skips whatever exists, so it composes with a repository already running.
- CreateApp creates a service: host project, its own solution, and the pair
  of data projects that keep repository contracts separate from their EF
  implementation. --no-database for a service owning no tables. Picks a
  port no other service has claimed.
- CreateAppLib creates a domain: Default for use cases, Models for
  entities, Tests. Wires Default to Models and never the reverse, since
  Models is what the data projects depend on.
- CreateLib creates a shared library under Libs/, dots nesting directories.

Documentation
docs/repository-layout.md covers setup, what each boundary is for, what
belongs in Libs/ and what does not, and the tool reference. tools/README.md
becomes a short reference pointing at it. The root README gains the layout
as an explicitly optional section.

Two things the end-to-end test caught
- The generated Directory.Build.props applied to the tools themselves, so
  a repo targeting net9.0 produced scripts the net10 runtime refused to
  launch. tools/Directory.Build.props now pins the scripts and does not
  inherit the repository's settings.
- Generated repos targeted net9.0. The libraries stay there so net9 apps
  can consume them, but a newly scaffolded repo should not start a version
  behind; templates now generate net10.0.

publish-all.sh keeps InitRepo's pinned version in step with the release, so
scaffolding cannot point at a version predating its own tooling.

Verified from an empty directory: InitRepo, CreateApp, CreateAppLib,
CreateLib and AddEntity produce a repository that builds against the packed
packages, passes ValidateConventions, and serves a request with the
request-ID middleware live. Every tool is idempotent — second runs produce
no diff.
tools/README.md had been slimmed to a table when the layout guide was
added, which left it pointing at the guide for everything and carrying no
usage of its own. SyncAllProjects had no worked example anywhere.

Each tool now gets a section with its real options and runnable commands,
verified against the CommandOption declarations rather than written from
memory. SyncAllProjects gains the two occasions you actually run it by
hand: a conflicted solution file after a merge, and moving a project
outside the tools.

The guide keeps the narrative and now points at the reference for options.
Adopting the layout meant cloning this repository and copying tools/ out of
it — for a consumer who otherwise only needs the NuGet packages, with no
version link between the scripts they copied and the packages they
reference, and no way to update.

    dotnet new install MagicCSharp.Templates
    dotnet new magiccsharp-repo -n Acme

Lays down magiccsharp.json, Directory.Build.props, central package
management, the all-projects solution, Apps/, Libs/ and tools/, with the
prefix substituted everywhere — including the scripts' own --help examples,
so a generated repository's tooling talks about its own namespaces.

--MagicCSharpVersion and --TargetFramework are parameters. publish-all.sh
updates the version default alongside InitRepo's, so a generated repository
cannot pin a version predating its own tooling.

The scripts are copied from tools/ by an MSBuild target when the package is
built, not duplicated into the template, so editing a script updates both.
The copy is gitignored.

InitRepo stays: it is now the path for adding the layout to a repository
that already exists, which the template cannot do.

Two packaging bugs found by installing it: ContentTargetFolders nested the
already-content-rooted paths a second time, and NuGet's default exclusion
of dotfiles dropped the .gitkeep files that create Apps/ and Libs/.

Verified end to end: install the packed template, generate Contoso, then
CreateApp, CreateAppLib and AddEntity produce a repository that builds
against the packed packages, lints clean, and serves a request with the
request-ID middleware live. Namespaces are Contoso throughout.
Adopting the layout meant either cloning this repository and copying tools/
out of it, or committing the tooling via the dotnet new template. Neither
suits someone who just wants the scaffolding available everywhere.

    curl -fsSL https://raw.githubusercontent.com/MagicDoorInc/MagicCSharp/master/install.sh | bash

    mcs init --prefix Acme
    mcs create-app --name Shop --database shop

Installs the scripts to ~/.magiccsharp/tools and a dispatcher to
~/.magiccsharp/bin. Not ~/.magicdoor: MagicDoor's own md CLI already keeps
state there, and MagicCSharp is a framework other teams use — including,
eventually, MagicDoor's backend as one consumer among others.

Scripts now resolve templates from MAGICCSHARP_TOOLS_DIR when set, falling
back to tools/Templates, so a repository that vendored the tools keeps
working and both install modes are supported.

Update check
Once a day, in the background, printing a one-line notice on the next
command. It compares commit SHAs rather than package versions: the tools
are installed from a git ref, and scripts/version.txt tracks NuGet releases
on its own schedule — it currently says 0.0.11 while nuget.org has 0.0.13,
which would have made a version comparison lie. Never blocks a command,
only speaks on a terminal, and MAGICCSHARP_NO_UPDATE_CHECK=1 disables it.

The installer checks for SDK 10 before writing anything, since the scripts
are file-based apps and would otherwise fail confusingly on first use.

One bug worth naming: under set -euo pipefail, pending="$(cat missing |
tr ...)" aborts the dispatcher, because cat's failure propagates through
pipefail into the assignment. It only reproduced on a terminal — piped runs
returned earlier at the tty guard. Every state read now goes through one
helper that treats a missing file as empty.

Verified: install into a sandbox home, then init, create-app,
create-domain, add-entity, create-lib, sync and validate in a directory
with no tools/ of its own, producing a repository that builds. The update
check was exercised by faking a stale commit — flags in the background,
notifies on the next run, stays quiet within 24h.
The generators' templates were only reachable inside tools/, so a team that
wanted its own DAL shape had no way to say so short of forking the tools.

Templates now install to ~/.magiccsharp/templates, beside the scripts rather
than inside them, and resolve through three layers with the first match
winning:

  1. .magiccsharp/templates/  in the repository, committed and shared
  2. ~/.magiccsharp/templates/  installed with the tools
  3. tools/Templates/  a repository that vendored the tools

Resolution is per file. Overriding Entities/dal.cs.hbs leaves the other
eighteen built-in and still tracking upstream, so taking one override does
not mean owning all of them forever.

The directory is the "templates" key of magiccsharp.json, defaulting to
.magiccsharp/templates. Point it at a shared submodule, or set it to "" to
turn overrides off — eject then refuses rather than writing somewhere that
would be ignored. InitRepo writes the key explicitly so the mechanism is
discoverable from the config rather than only from documentation.

New Templates tool, exposed as `mcs templates`:
  list [--overridden]   every template, and which layer provides it
  where                 the layers, in search order
  eject <path>          copy a built-in in to customise

Verified: eject a template, edit it, generate an entity, and the generated
file carries the edit while a non-overridden template still produces the
built-in output — and the result builds. A custom "templates" path is
honoured, and "" disables overrides.
The exported line was hardcoded to $HOME/.magiccsharp/bin while the install
location comes from MAGICCSHARP_HOME, so a custom install wrote a PATH entry
for a directory that does not exist and left mcs unreachable. The "already
configured" check had the same assumption and would have appended a second
line on every re-run of such an install.

Both now derive from the resolved bin directory, still written back through
$HOME when it sits under the home directory so the profile stays portable
between machines.

Found by actually exercising the branch: every previous test passed
NO_MODIFY_PATH=1, so the profile-writing path had never run. Now covered for
zsh, bash with an existing .bash_profile, an unrecognised shell, a re-run,
a custom MAGICCSHARP_HOME, and NO_MODIFY_PATH.

Documented what the installer writes and where.
    dotnet tool install -g MagicCSharp.Cli
    mcs init --prefix Acme

The scripts worked, but each was a standalone file, so shared code had to be
copied between them: TemplateResolver lived in six files, RepoConfig in four,
about 860 of 2,593 lines duplicated. Fixing anything meant editing it six
times — the layered template resolver went in via a scripted patch, which is
the smell showing.

One project instead of eight files. The duplication is gone, and the logic
is testable for the first time: 50 tests covering template resolution,
naming, source edits and solution-file handling, where there were none.

dotnet tool also replaces machinery written by hand. install.sh — 250 lines
of bash doing shell detection, PATH editing, and a daily update check — is
deleted, along with the vendored tools/ directory. `dotnet tool update` is
the update story, and a tool manifest is the team story: pin the version in
.config/dotnet-tools.json, `dotnet tool restore`, `dotnet mcs`. The
dotnet new template now scaffolds that manifest instead of copying scripts.

Templates are embedded in the assembly rather than shipped as loose files,
so there is no path to resolve and nothing to go missing. Overrides are
unchanged — .magiccsharp/templates/ still wins, per file — and now resolve
against the built-ins rather than a third on-disk layer.

One bug worth naming, found because the tests asked for every template by
name: MSBuild infers a culture from the second-to-last extension, so
entity.cs.hbs reads as culture "cs" — Czech — and the eight *.cs.hbs
templates were routed into a cs/ satellite assembly. The build reported all
19 embedded resources; only 11 reached the DLL, and every generator that
writes C# silently produced nothing. WithCulture="false" fixes it, and a
test now asserts all 19 are present.

Verified: pack, install globally, and run init, create-app, create-domain,
add-entity, create-lib, validate and sync in an empty directory, producing a
repository that builds and serves a request. Then again through the template
and a tool manifest with `dotnet mcs`. Template overrides confirmed to apply
per file. Every command idempotent.
scripts/version.txt said 0.0.11 while nuget.org had 0.0.12 and 0.0.13
published, so the file the release script bumps from was two releases behind
what consumers could actually install. A --patch release would have tried to
publish 0.0.12 again.

The dotnet new template pins a MagicCSharp version in the repositories it
scaffolds, in both .config/dotnet-tools.json and Directory.Packages.props,
and both come from one default in template.json. That sync was lost when
install.sh went away, so a generated repository would have referenced
whatever version happened to be hardcoded. Restored.

Also adds --dry-run. The script bumps the version before it packs, so
verifying that a release builds used to leave the bump and thirteen .nupkg
files behind; now it restores the worktree and deletes them.
The override mechanism was described in passing — a sentence saying the
directory could be "a git submodule shared across repositories" — with no
instructions for actually doing it. Once a company has more than one
repository that is the case that matters, because copying overrides between
them means house style drifts and a fix to one never reaches the others.

docs/template-overrides.md covers both: the one-off eject for a single
repository, and building a template repository other repositories consume as
a submodule at .magiccsharp/templates — creating it, wiring it in, the clone
and CI flags a submodule needs, and how changing a shared template rolls out
per repository rather than to everyone at once.

Two fixes found writing it:

- `mcs templates eject` printed a red "magiccsharp.json not found" before
  succeeding when run outside a repository. The template commands only need
  the config to locate the override directory, which has a default, so a
  missing config is ordinary rather than an error. They use a quiet load
  now, which is what makes it possible to build a template repository in an
  empty directory — the first step of the documented flow.
- The documented template variables were written from memory and were wrong.
  Extracted them from the templates instead, per family: Repo gets version
  and target_framework, Apps gets database.enabled and friends, Libraries
  gets assembly_name, Entities gets the entity names and flags.

Also converts the last `dotnet run tools/X.cs` invocations left in the guide
and the template README to mcs commands.
It was displaced when the CLI section replaced the installer's, leaving the
mechanism described only in a half-sentence. Also corrects the dotnet new
template's description, which still said it ships a tools/ directory.
The README only carried a link to the override doc, so someone reading it
front to back would not learn the mechanism exists — and it is one of the
reasons to adopt the layout at all.

Adds a short section: what eject does, that the copy is committed and
reverting is deleting it, that resolution is per file so taking one template
does not mean owning all nineteen, and the shared template repository for
teams with more than one repository. The detail stays in
docs/template-overrides.md.

Every internal link in the README and both docs verified to resolve.
The docs stated that an override "is committed like any other file" — true,
but passive, and easy to read past. Committing that directory is the whole
mechanism for sharing templates with a team, so it should be an instruction
rather than an observation.

`mcs templates eject` now prints the git add command, at the moment the file
has just been created and nothing yet suggests it is not shared. Both the
README and the override guide give the command rather than describing it.

It also checks whether the path is git-ignored and warns instead, because an
ignored override works perfectly for whoever wrote it and reaches nobody —
the kind of thing that goes unnoticed for months. Not being in a git
repository at all counts as not ignored, since that is how a shared template
repository starts out.

Three tests: the override path eject writes to is the one the generators
read back, a path outside any repository is not reported as ignored, and a
missing git does not take the command down.
Three places where the framework defined half of something and left the
application to supply the rest.

Scheduling did not work at all. ScheduledBackgroundService resolves
IScheduleStore with GetRequiredService, and the packages shipped no
implementation — so "drift-free scheduling", advertised in the README, threw
the moment anyone used it. Adds InMemoryScheduleStore and
AddMagicScheduling(), which also registers a file-system lock provider. Both
defaults are single-machine and the XML docs say exactly what that costs:
two instances each keep their own schedule state and both run the job.
Registering your own first wins.

Nothing mapped exceptions to responses. The framework throws
NotFoundException for a row that is not there, and it reached the caller as
a 500 with a stack trace. AddMagicErrorHandling/UseMagicErrorHandling maps
it to 404, validation and argument failures to 400, and a cancelled request
to 499 rather than counting it as a server error. Outside Development an
unexpected exception returns a generic message and logs the detail — its
message routinely carries a connection string.

Adds HttpException and the status-carrying subclasses for when a use case
genuinely means a status code, while pointing at the domain exception as the
better default: a use case that throws NotFoundException still works from a
queue consumer.

Also ValidateServices, resolving every registration at startup so a miswired
dependency fails the deploy rather than the first request that needs it.

Generated apps get all three wired in.

One bug found by running it: ValidateServices tried to resolve keyed
services, which need their key, so an ordinary app using AddOpenApi failed
to boot. A preflight that stops startup on a false positive is worse than no
preflight. Keyed registrations are skipped.

Verified against a running generated service in Production: /probe/missing
returns 404 with the entity name, /probe/boom returns a generic 500 with the
connection string in the log and not the response.
Two things.

MagicCSharp.App bundles the four packages a web service needs — core,
AspNetCore, Events, Scheduling — behind builder.AddMagicApp() and
app.UseMagicApp(builder). A generated Program.cs went from thirteen wiring
lines to two, and from four package references to two.

It is a shortcut, not a layer: everything it calls is public on the package
that owns it, so outgrowing the defaults means replacing two lines with
five. MagicAppOptions turns any piece off, and registering a transport or a
schedule store first means the single-machine defaults step aside rather
than fight. The granular packages remain the answer for a worker, a console
app or a test project.

Also fixes AddMagicCSharp, which did not forward the assembly filter its own
AddMagicUseCases accepts.

README: the Quick Start told people to install five packages and wire them
by hand, which is now the third-best way to start and was the only one
documented. It leads with the CLI, then MagicCSharp.App for an existing
project, then the granular list.

The repository layout was three-quarters of the way down and explained what
the structure is without saying why anyone would want it. It now opens with
what it buys: one repository so a cross-service change is one pull request,
per-service solutions so you still build one at a time, a domain that does
not know how it is stored, every entity looking the same, and conventions
that are checked rather than agreed.
Package metadata. Three packages shipped
RepositoryUrl=github.com/yourusername/magiccsharp in their nuspec, and the
author name disagreed between packages. src/Directory.Build.props now holds
the shared metadata, the version, and GenerateDocumentationFile — the XML
comments were not in any of the twelve nupkgs, so none of that guidance
reached IntelliSense. Symbols and SourceLink ship too.

That also collapses the version bookkeeping. version.txt said 0.0.13, every
csproj said 0.0.11 and the dotnet new template pinned 0.1.0, because the
release script sed-ed fourteen files that could drift. It rewrites one line
now, and --dry-run restores files one at a time — a single git checkout
aborted on the untracked props file and silently left the rest bumped.

Kafka delivery claims. The README promised "Guaranteed Delivery — persisted
before returning", "Manual Commit" and at-least-once. None held:
ProduceAsync's task was discarded behind a TODO, so a rejected message
failed silently; EnableAutoCommit was unset, so Confluent's default committed
offsets on a timer and made the listener's careful manual commit
decorative. Both fixed in code — the produce is observed and failures
logged, auto-commit and auto-offset-store are off.

The third claim could not be fixed by config: AsyncEventDispatcher catches
handler exceptions so one failure cannot stop the others, which means the
transport always sees success. Both transport READMEs now say what is true —
at-least-once to the dispatcher, at-most-once per handler — and what to do
about it.

Events README documented an instance Priority property while the dispatcher
reads a static one, so every priority in the docs was silently ignored. Also
adds the registration order: RegisterMagicEvents discovers handlers and must
come before any transport, which alone only registers the dispatcher.

Root README's Setup block had the same missing call and predates
MagicCSharp.App.
CLI
- create-domain --name Orders produced Apps/Shop/Shop.Domains//Default with a
  double slash and a wrong assembly name, then add-entity failed on the
  projects it did not find. The short form is now accepted and reported.
- init writes a .gitignore. Without one the first build left hundreds of
  bin/ and obj/ files staged.
- --version worked but crashed; a bad flag printed a Spectre stack trace.
  Parse and runtime errors now print their message and point at --help.

Error handling, all found by looking at real responses
- Response.ContentType was set and then overwritten by WriteAsJsonAsync, so
  problem responses went out as application/json. The content type goes to
  the write now.
- The requestId in the body was Kestrel's connection counter while the
  header carried the real id. It comes from IRequestIdHandler, and
  UseRequestId now runs before the error handler so the id exists when the
  problem is written — and so the handler's own log line carries it.
- UseExceptionHandler logged every handled exception at Error, including the
  404s this module treats as routine. Replaced with explicit middleware, so
  there is one log line at the level the status implies.
- X-Request-ID was echoed back unbounded; a 3000-character header ended up
  in every log line for the request. Capped at 128 and to printable ASCII.

Naming, before any of it is published under these names
- RegisterMagicEvents/RegisterLocalMagicEvents/RegisterMagicKafkaEvents/
  RegisterMagicSQSEvents/RegisterSnowflakeKeyGen become AddMagicEvents/
  AddLocalMagicEvents/AddMagicKafkaEvents/AddMagicSqsEvents/
  AddSnowflakeKeyGen, matching every other registration method. The old
  names remain as [Obsolete] forwarders.
- AddLocalMagicEvents is self-sufficient. It used to register only
  IEventDispatcher, so calling it alone — which the README told people to do
  — failed at resolution on the first dispatch. AddMagicEvents is now
  idempotent, so the transports calling it does not double-register.
- HttpExceptions' NotFoundException and NotImplementedException collided
  with the domain exception and with System's under implicit usings; the
  latter made any file importing the namespace fail to compile. Renamed
  HttpNotFoundException and HttpNotImplementedException.

Correctness
- SearchText stripped every non-ASCII letter, so "Søgaard" indexed as
  "sgaard" and no one searching the actual name found it.
- MagicEventSerializer keyed events by simple name via ToDictionary, so two
  same-named events in different namespaces crashed startup with no clue
  which. It now names both.

CI
.github/workflows/ci.yml builds, tests and lints, then does what the
CHANGELOG claims and nothing automated: packs all fourteen packages, installs
the CLI from that feed, scaffolds two services, a domain, two entities and a
shared library from an empty directory, builds it, re-runs every command and
fails if the worktree changed, then boots a service and checks it answers
with a request-id header.
Building a two-service example repository with the mcs CLI, then running it
against Postgres, turned up five bugs. Each was silent: the code built, the
service started, and the wrong thing happened without a word.

Event handlers in a domain project never ran. Discovery scanned
AppDomain.CurrentDomain.GetAssemblies(), which lists only assemblies .NET has
already loaded, and it loads one lazily on first use of a type in it. A domain
project holding nothing but handlers is referenced by the host and used by
nothing, so it was not there. The dispatch returned 202 and no handler received
it. Reading the reference graph does not fix this either: the C# compiler
leaves a reference out of the compiled metadata when no type from it is used,
which is exactly that project. ApplicationAssemblies now loads the managed DLLs
deployed next to the executable, so what gets discovered follows what the
application ships. Both use-case and event-handler discovery go through it, and
the old advice to touch a type per project at startup is retired.

create-domain scaffolded a domain and left it unreferenced by the service that
owns it, so the assembly was never deployed and the above could not have saved
it anyway. It now adds the reference, as create-app already does for the data
projects.

The domain project template composed its own assembly name as
{prefix}.Libraries.{name}, leaving the service out, so two services with a
same-named domain both produced Acme.Libraries.Domains.Orders.

The HTTP layer ignored the framework's own JSON conventions. JsonDefaults was
wired into Postgres jsonb columns and nowhere else, so the same enum was a name
in the database and a number over the wire, and Optional<T> did not round-trip
through a request body — a PATCH could not tell "set this to null" from "do not
touch this", the one distinction that type exists to make. Only the two
converters are applied, for controllers and minimal APIs both; JsonDefaults
wholesale also sets IgnoreReadOnlyProperties, which suits a jsonb column and
would drop Pagination.TotalPages and every other computed property from a
response.

Also, smaller:

- GetOrThrow on IRepository. Update and Delete throw NotFoundException for a
  missing key while Get returned null, so every endpoint fetching by id wrote
  its own throw to get a 404 out of the error handling.
- DB_VERIFY_CONNECTION turns off the startup connection check from
  configuration. Opening a connection while registering is right by default,
  but there was no way to turn it off without editing the registration, which
  blocked booting a service in a test that replaces every repository.
- create-domain and add-entity take a service name: --solution Shop rather than
  --solution Acme.Shop.slnx, and no flag at all when the repository has one
  service. More than one and no flag lists them rather than guessing.

152 tests, up from 127. The new MagicCSharp.App.Tests boots a real host; four
of its six tests failed before the JSON fix.
InsertBefore spliced the new line in at the anchor, which sits after its own
leading whitespace, so the caller's eight spaces were added to the anchor's
eight and the anchor was pushed onto a fresh line of stray spaces. Every
generated repositories module carried it, blank lines between each registration
included — and since it is the first generated code anyone reads, it is the
first impression the scaffolding makes.

It now reads the anchor's indentation and applies it, inserting after the last
statement so the blank line separating the body from `return services;` stays
where the template put it and successive entities group together.
The scaffold job commits a baseline so it can assert that re-running every mcs
command leaves the worktree untouched. A runner has no git identity configured
and none to derive — the account has no full name — so the commit failed with
"empty ident name" and took the job with it.

Passed per-command rather than configured globally: the repository it commits to
is a scratch one under RUNNER_TEMP, and nothing else in the workflow commits.

Both jobs run clean locally now, the whole scaffold sequence included: pack
thirteen packages, install the CLI from that feed, scaffold two services with a
domain, two entities and a shared library, build, re-run every command with no
change, and boot the service to check the body and the X-Request-ID header.
@KRSogaard
KRSogaard merged commit af6bc0d into master Sep 7, 2026
2 checks passed
@KRSogaard
KRSogaard deleted the repository-layout branch September 7, 2026 07:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant