A worked example of the MagicCSharp repository layout: two services in one repository, sharing an event contract, with the whole slice filled in from HTTP endpoint down to database table.
It exists to be read and copied. Every file here was either produced by the mcs command line tool or
written by hand on top of it, and the README says which is which.
Two services, deliberately unalike, because the layout has to hold both:
Apps/Shop — the full stack. Postgres behind Entity Framework, an Order entity keyed by a Snowflake
id, an ApiKey entity keyed by an unguessable string, use cases, and a controller.
Apps/Notifications — no database at all. It exists to react to what Shop does.
Libs/Events — the one thing both services share: OrderPlacedEvent. Neither service references the
other. Shop publishes the event, Notifications handles it, and the only code in common is the record itself.
Acme.All.slnx every project — generated by `mcs sync`
Acme.Shop.slnx one service, for day-to-day work
Acme.Notifications.slnx
Apps/Shop/
Shop.App/ Program.cs and configuration — no controllers of its own
Shop.App.Tests/ the service end to end, no database
Shop.Domains/Orders/
Default/UseCases/ PlaceOrderUseCase, GetOrdersUseCase
Models/Entities/ Order, OrderEdit, OrderFilter, ApiKey
Tests/
App/Default/ OrdersController — this domain's own endpoints
Data/
Data.Models/Repositories/ IOrdersRepository, IApiKeysRepository
Data.EntityFramework/ OrderDal, OrdersEfRepository, MagicShopContext
Migrations/ InitialSchema — orders and api_keys
Data.EntityFramework.Tests/ the repository against a real Postgres
Apps/Notifications/
Notifications.App/ Program.cs, an endpoint standing in for a queue listener
Notifications.Domains/Delivery/
Default/EventHandlers/ OrderPlacedHandler
Tests/
Libs/Events/Default/ OrderPlacedEvent — shared by both services
This needs MagicCSharp 0.1.0, which is not on nuget.org yet. The example uses
GetOrThrow,DB_VERIFY_CONNECTIONand the assembly-loading fix that makes event handlers in a domain project actually run — none of which is in 0.0.13, the newest published version. Until 0.1.0 ships, build it from the MagicCSharp checkout and point this repository at it: see Building against a local MagicCSharp below.
Once 0.1.0 is published, a clone builds:
dotnet build Acme.All.slnx
dotnet test Acme.All.slnxTwenty-eight tests. Sixteen need nothing; twelve start a Postgres container. To skip those:
dotnet test Acme.All.slnx --filter "Category!=Database"To run the Shop service you do need a database:
docker run -d --name shop-db -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:17
# Creates the orders and api_keys tables from the committed migration.
dotnet tool install -g dotnet-ef
dotnet ef database update \
--project Apps/Shop/Data/Data.EntityFramework \
--startup-project Apps/Shop/Shop.App
dotnet run --project Apps/Shop/Shop.App
curl -X POST localhost:5200/orders -H 'Content-Type: application/json' \
-d '{"customerId": 7, "total": 42.50}'The response comes back with a Snowflake id and "status": "Pending" — a name, not a number, matching what
goes into the column.
appsettings.Development.json holds the settings above, and every one of them is overridden by an
environment variable of the same name. So pointing at a server on another port takes no edit:
export DB_HOST=localhost DB_PORT=55432 DB_NAME=shop DB_USER=postgres DB_PASSWORD=secret
# Create the database once — `database update` creates tables, not the database itself.
createdb -h $DB_HOST -p $DB_PORT -U $DB_USER $DB_NAME
dotnet ef database update \
--project Apps/Shop/Data/Data.EntityFramework \
--startup-project Apps/Shop/Shop.App
dotnet run --project Apps/Shop/Shop.Appdotnet ef reads the same five variables through MagicShopContextFactory, so the migration and the
service always agree about which database they mean.
Notifications needs nothing:
dotnet run --project Apps/Notifications/Notifications.App
curl localhost:5201/internal/events/handlersThat last one lists the event handlers discovery actually found — a quick way to check a handler is wired before wondering why it never runs.
Everything structural came from the CLI. Install it:
dotnet tool install -g MagicCSharp.CliThen, from an empty directory:
mcs init --prefix Acme
mcs create-app --name Shop --database shop
mcs create-app --name Notifications # no --database: no data projects
mcs create-domain --solution Shop --name Orders --models --tests
mcs create-domain --solution Shop --name Orders.App # the domain's own endpoints
mcs add-entity --solution Shop --domain Orders --name Order --paginated
mcs add-entity --solution Shop --domain Orders --name ApiKey --use-key
mcs create-domain --solution Notifications --name Delivery --models --tests
mcs create-lib --name Events --tests # Libs/Events, shared by both--solution takes a service name; with only one service in the repository you can leave it out entirely.
Running any of these twice changes nothing, so they are safe to re-run.
The CLI scaffolds structure, not behaviour. These are the files worth reading, and none of them was generated:
| File | What it shows |
|---|---|
Libs/Events/Default/OrderPlacedEvent.cs |
A cross-service contract carrying ids and primitives, never an entity |
Apps/Shop/Shop.Domains/Orders/Default/UseCases/PlaceOrderUseCase.cs |
Business logic with no HTTP and no EF, so a test needs neither |
Apps/Shop/Shop.Domains/Orders/Default/UseCases/GetOrdersUseCase.cs |
Reads through a use case too, and GetOrThrow instead of a null check |
Apps/Shop/Shop.Domains/Orders/App/Default/OrdersController.cs |
A controller that calls use cases and touches no repository, living with the domain it serves |
Apps/Shop/Data/Data.EntityFramework/Repositories/OrdersEfRepository.cs |
A real ApplyFilter over the generated base |
Apps/Notifications/.../EventHandlers/OrderPlacedHandler.cs |
The other end of the contract, in a service that shares no code with the publisher |
Apps/Shop/Shop.App.Tests/OrdersEndpointTests.cs |
The whole pipeline under test with one fake substituted |
Apps/Shop/Data/Data.EntityFramework.Tests/OrdersEfRepositoryTests.cs |
The repository against a real database, running the committed migration |
A use case is constructible in a test with two fakes. PlaceOrderUseCaseTests builds
PlaceOrderUseCase directly — no host, no configuration, no database, no mocking framework. That is the
return on keeping HTTP and Entity Framework out of it.
Nothing registers itself by hand. No services.AddScoped<IPlaceOrderUseCase, PlaceOrderUseCase>()
anywhere. MagicCSharp finds every IMagicUseCase and every IEventHandler<T> at startup. Repositories are
the deliberate exception — they are listed explicitly in ShopRepositoriesModule, so what talks to the
database is readable in one place.
404 is not written in the controller. GetOrdersUseCase.ById calls GetOrThrow, the repository raises
the domain's NotFoundException, and the error handling turns it into a 404 with a problem+json body.
The controller has no null branch. OrdersEndpointTests asserts the status, the content type and the body.
The request id in an error body matches the header. Quote the id from a bug report and it finds the request in the logs. There is a test for it, because it was once wrong.
Enums are names on the wire, not numbers. status is "Pending", in the API and in the database
column. Inserting a member into the middle of an enum renumbers everything after it, and a client that
hard-coded 0 is then wrong with nothing to report it. The cost is that a client needs
JsonStringEnumConverter to read the response — OrdersEndpointTests shows the two lines that takes.
Controllers live with their domain, not in the host. Shop.App has a Program.cs and no endpoints of
its own. OrdersController is in Shop.Domains/Orders/App, beside the use cases it calls. The host
references that project and serves its routes with nothing else wired — no AddApplicationPart. Add a
second domain and its endpoints do not land in the same folder as this one's.
The repository is tested against a real database, not a fake one. A use case is worth testing with
fakes; a repository is mostly translation — a filter into SQL, a row into an entity — and only Postgres can
say whether the translation is right. OrdersEfRepositoryTests starts a container, runs the committed
migration rather than building the schema from the model (so a migration that has drifted fails here rather
than at deploy), and checks the things that have no in-memory equivalent: numeric keeping a decimal's
scale, the status column holding Paid rather than 1, timestamptz coming back as UTC, and paging that
starts at page one.
Two services, one repository, no shared code. Shop does not reference Notifications and Notifications
does not reference Shop. Swapping AddLocalMagicEvents for Kafka or SQS is a registration change in
Program.cs and nothing else — the handler does not change.
Working on the framework itself rather than using it:
# in the MagicCSharp checkout, pack whatever you changed
for p in MagicCSharp MagicCSharp.App MagicCSharp.AspNetCore MagicCSharp.Data \
MagicCSharp.Data.EntityFramework MagicCSharp.Data.Postgres \
MagicCSharp.Events MagicCSharp.Scheduling MagicCSharp.Testing; do
dotnet pack src/$p/$p.csproj -c Release -o /tmp/mcfeed -p:Version=0.2.0-local
doneThen here: uncomment the local source in nuget.config, point it at /tmp/mcfeed, and set the MagicCSharp
versions in Directory.Packages.props to 0.2.0-local.
Give the local build a version that is not on nuget.org. Reusing a published version means NuGet serves the cached real package instead of yours, and the change you are testing silently is not there.