Skip to content

Repository files navigation

MagicCSharp Example Project

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.

What is in it

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

Running it

This needs MagicCSharp 0.1.0, which is not on nuget.org yet. The example uses GetOrThrow, DB_VERIFY_CONNECTION and 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.slnx

Twenty-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.

Against a Postgres you already have

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.App

dotnet 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/handlers

That last one lists the event handlers discovery actually found — a quick way to check a handler is wired before wondering why it never runs.

How it was generated

Everything structural came from the CLI. Install it:

dotnet tool install -g MagicCSharp.Cli

Then, 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.

What was written by hand

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

Things this example is making a point about

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.

Building against a local MagicCSharp

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
done

Then 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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages