From feef095cad467af601266c8356c3cc6e01ff4805 Mon Sep 17 00:00:00 2001 From: Stephen Walsh <868017+stphnwlsh@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:08:16 -0600 Subject: [PATCH 1/6] Add "Make it yours" guide and update sidebar links --- astro.config.mjs | 2 +- .../docs/minimalapi/getting-started.mdx | 1 + .../docs/minimalapi/guides/make-it-yours.mdx | 376 ++++++++++++++++++ 3 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 src/content/docs/minimalapi/guides/make-it-yours.mdx diff --git a/astro.config.mjs b/astro.config.mjs index 14d363d..5967a0d 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -12,7 +12,7 @@ const sidebars = { { label: 'Start here', items: ['minimalapi/getting-started', 'minimalapi/guides/run-locally'] }, { label: 'Guides', - items: ['minimalapi/guides/add-an-endpoint', 'minimalapi/guides/database', 'minimalapi/guides/secure-the-api', 'minimalapi/guides/ship-the-container', 'minimalapi/guides/set-up-ci'], + items: ['minimalapi/guides/make-it-yours', 'minimalapi/guides/add-an-endpoint', 'minimalapi/guides/database', 'minimalapi/guides/secure-the-api', 'minimalapi/guides/ship-the-container', 'minimalapi/guides/set-up-ci'], }, { label: 'Reference', items: [{ autogenerate: { directory: 'minimalapi/reference' } }] }, { diff --git a/src/content/docs/minimalapi/getting-started.mdx b/src/content/docs/minimalapi/getting-started.mdx index 2587918..16ee716 100644 --- a/src/content/docs/minimalapi/getting-started.mdx +++ b/src/content/docs/minimalapi/getting-started.mdx @@ -95,6 +95,7 @@ You need the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0). [ + diff --git a/src/content/docs/minimalapi/guides/make-it-yours.mdx b/src/content/docs/minimalapi/guides/make-it-yours.mdx new file mode 100644 index 0000000..1c44cf7 --- /dev/null +++ b/src/content/docs/minimalapi/guides/make-it-yours.mdx @@ -0,0 +1,376 @@ +--- +title: "Make it yours" +description: "Add your own entity, migration, use cases and endpoints, then clear out the sample movies, authors and reviews without dropping below 95% coverage." +sidebar: + order: 2 +--- + +import { Steps, Aside } from '@astrojs/starlight/components'; + +The first thing anyone wants to do after `dotnet new` is evict *Deep Space Gardeners* and put their own domain in its place. If you delete `Movies`, `Authors` and `Reviews` straight away, half the shared plumbing tests fail to compile and the rest drop below the 95% coverage gate because the paging, validation, logging, OpenAPI and telemetry code suddenly has nothing to exercise it. + +The painless order is the other way round: build your first vertical slice alongside the sample domain, prove it works end to end, and then delete the movies. This guide adds a `Cinema` slice (`GET /api/cinemas` and `POST /api/cinemas`) and clears out the sample code at the end. + + + + + +1. **Add the entity and use cases in `src/Application`** (`add-a-use-case` in `.agents/skills`). + + Add `Cinema` to `src/Application/Common/Enums/EntityType.cs`, then create the domain record and repository interface in `src/Application/Cinemas/`: + + ```csharp title="src/Application/Cinemas/Entities/Cinema.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Entities; + + public sealed record Cinema + { + public Guid Id { get; init; } + + public string Name { get; init; } = string.Empty; + } + ``` + + ```csharp title="src/Application/Cinemas/ICinemasRepository.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas; + + using Common.Paging; + using Entities; + + public interface ICinemasRepository + { + public Task CreateCinema(string name, CancellationToken cancellationToken); + + public Task> GetCinemas(PageRequest page, string? searchTerm, CancellationToken cancellationToken); + } + ``` + + Add a paged query in `src/Application/Cinemas/Queries/GetCinemas/` so `Common/Paging` stays covered when the sample queries go: + + ```csharp title="src/Application/Cinemas/Queries/GetCinemas/GetCinemasQuery.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemas; + + using Common.Paging; + using Entities; + using Mediator; + + public sealed record GetCinemasQuery : IRequest> + { + public string? SearchTerm { get; init; } + + public int Offset { get; init; } + + public int Limit { get; init; } = Paging.DefaultLimit; + } + ``` + + ```csharp title="src/Application/Cinemas/Queries/GetCinemas/GetCinemasHandler.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemas; + + using Common.Paging; + using Entities; + using Mediator; + + internal sealed class GetCinemasHandler(ICinemasRepository repository) : IRequestHandler> + { + public async ValueTask> Handle(GetCinemasQuery request, CancellationToken cancellationToken) + { + return await repository.GetCinemas( + new PageRequest(request.Offset, request.Limit), + request.SearchTerm, + cancellationToken); + } + } + ``` + + ```csharp title="src/Application/Cinemas/Queries/GetCinemas/GetCinemasValidator.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemas; + + using Common.Paging; + using FluentValidation; + + public sealed class GetCinemasValidator : AbstractValidator + { + public GetCinemasValidator() + { + _ = this.RuleFor(r => r.Offset).ValidOffset(); + _ = this.RuleFor(r => r.Limit).ValidLimit(); + _ = this.RuleFor(r => r.SearchTerm).ValidSearchTerm(); + } + } + ``` + + Add a command in `src/Application/Cinemas/Commands/CreateCinema/`: + + ```csharp title="src/Application/Cinemas/Commands/CreateCinema/CreateCinemaCommand.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Commands.CreateCinema; + + using Entities; + using Mediator; + + public sealed record CreateCinemaCommand : IRequest + { + public string Name { get; init; } = string.Empty; + } + ``` + + ```csharp title="src/Application/Cinemas/Commands/CreateCinema/CreateCinemaHandler.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Commands.CreateCinema; + + using Entities; + using Mediator; + + internal sealed class CreateCinemaHandler(ICinemasRepository repository) : IRequestHandler + { + public async ValueTask Handle(CreateCinemaCommand request, CancellationToken cancellationToken) + { + return await repository.CreateCinema(request.Name, cancellationToken); + } + } + ``` + + ```csharp title="src/Application/Cinemas/Commands/CreateCinema/CreateCinemaValidator.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Commands.CreateCinema; + + using FluentValidation; + + public sealed class CreateCinemaValidator : AbstractValidator + { + public CreateCinemaValidator() + { + _ = this.RuleFor(r => r.Name) + .NotEmpty() + .MaximumLength(100); + } + } + ``` + +2. **Persist and map it in `src/Infrastructure`** (`data-access-ef-core` and `mapping-with-mapperly` in `.agents/skills`). + + Add the EF Core model and configuration under `src/Infrastructure/Databases/MovieReviews/`: + + ```csharp title="src/Infrastructure/Databases/MovieReviews/Models/Cinema.cs" + namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Models; + + internal sealed record Cinema : BaseEntity + { + public string Name { get; set; } = string.Empty; + } + ``` + + ```csharp title="src/Infrastructure/Databases/MovieReviews/Configuration/CinemaConfiguration.cs" + namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Configuration; + + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Models; + + internal sealed class CinemaConfiguration : BaseConfiguration + { + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + _ = builder.Property(e => e.Name) + .HasMaxLength(100) + .IsRequired(); + } + } + ``` + + Add `internal DbSet Cinemas { get; set; }` to `MovieReviewsDbContext.cs`, add the Mapperly method to `Mapping/MovieReviewsMapper.cs`: + + ```csharp title="src/Infrastructure/Databases/MovieReviews/Mapping/MovieReviewsMapper.cs" + using AppCinema = Application.Cinemas.Entities.Cinema; + using InfraCinema = Models.Cinema; + ``` + + ```csharp title="src/Infrastructure/Databases/MovieReviews/Mapping/MovieReviewsMapper.cs" + [MapperIgnoreSource(nameof(InfraCinema.DateCreated))] + [MapperIgnoreSource(nameof(InfraCinema.DateModified))] + internal partial AppCinema ToDomain(InfraCinema cinema); + ``` + + Implement `ICinemasRepository` on `EntityFrameworkMovieReviewsRepository.cs` and register it in `src/Infrastructure/DependencyInjection.cs`: + + ```csharp title="src/Infrastructure/Databases/MovieReviews/EntityFrameworkMovieReviewsRepository.cs" + public async Task CreateCinema(string name, CancellationToken cancellationToken) + { + var utcNow = this.timeProvider.GetUtcNow().UtcDateTime; + var cinema = new Cinema + { + Name = name, + DateCreated = utcNow, + DateModified = utcNow + }; + + _ = await this.context.Cinemas.AddAsync(cinema, cancellationToken); + _ = await this.context.SaveChangesAsync(cancellationToken); + + return this.mapper.ToDomain(cinema); + } + + public async Task> GetCinemas(PageRequest page, string? searchTerm, CancellationToken cancellationToken) + { + IQueryable cinemas = this.context.Cinemas.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(searchTerm)) + { + var term = searchTerm.Trim(); + cinemas = cinemas.Where(c => EF.Functions.Like(c.Name, $"%{term}%")); + } + + var totalCount = await cinemas.CountAsync(cancellationToken); + var items = await cinemas + .OrderBy(c => c.Name) + .ThenBy(c => c.Id) + .Skip(page.Offset) + .Take(page.Limit) + .Select(c => this.mapper.ToDomain(c)) + .ToListAsync(cancellationToken); + + return new Page(items, page.Offset, page.Limit, totalCount); + } + ``` + + ```csharp title="src/Infrastructure/DependencyInjection.cs" + _ = services.AddScoped(sp => sp.GetRequiredService()); + ``` + +3. **Create the migration** (`database-migrations` in `.agents/skills`). + + ```bash + dotnet tool install --global dotnet-ef + dotnet ef migrations add AddCinemas \ + --project ./src/Infrastructure \ + --output-dir Databases/MovieReviews/Migrations + ``` + + Put `// ` at the top of the new `_AddCinemas.cs` file (EF Core already puts it on `.Designer.cs`) so the style analysers skip generated migration code. + +4. **Expose the endpoints in `src/Presentation`** (`expose-a-use-case` in `.agents/skills`). + + Add a request DTO in `src/Presentation/Requests/CreateCinemaRequest.cs` and the endpoint group in `src/Presentation/Endpoints/CinemaEndpoints.cs`: + + ```csharp title="src/Presentation/Requests/CreateCinemaRequest.cs" + namespace WorksOnMyMachine.Movies.Presentation.Requests; + + /// + /// The details of a new Cinema to create. + /// + /// The name of the cinema. + public sealed record CreateCinemaRequest(string Name); + ``` + + ```csharp title="src/Presentation/Endpoints/CinemaEndpoints.cs" + namespace WorksOnMyMachine.Movies.Presentation.Endpoints; + + using Application.Common.Paging; + using Mediator; + using Microsoft.AspNetCore.Http.HttpResults; + using Microsoft.AspNetCore.Mvc; + using Requests; + using Commands = Application.Cinemas.Commands; + using Entities = Application.Cinemas.Entities; + using Queries = Application.Cinemas.Queries; + + internal static class CinemaEndpoints + { + internal static WebApplication MapCinemaEndpoints(this WebApplication app) + { + var root = app.MapGroup("/api/cinemas") + .WithTags("Cinemas"); + + _ = root.MapGet("/", GetCinemas) + .WithName(nameof(GetCinemas)) + .Produces>() + .ProducesValidationProblem() + .ProducesProblem(StatusCodes.Status500InternalServerError); + + _ = root.MapPost("/", CreateCinema) + .WithName(nameof(CreateCinema)) + .Accepts("application/json") + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem() + .ProducesProblem(StatusCodes.Status500InternalServerError); + + return app; + } + + /// + /// Search Cinemas + /// + public static async Task>> GetCinemas( + [FromServices] ISender sender, + CancellationToken cancellationToken, + [FromQuery] string? query = null, + [FromQuery] int offset = 0, + [FromQuery] int limit = Paging.DefaultLimit) + { + return TypedResults.Ok(await sender.Send(new Queries.GetCinemas.GetCinemasQuery + { + SearchTerm = query, + Offset = offset, + Limit = limit + }, cancellationToken)); + } + + /// + /// Create a Cinema + /// + public static async Task> CreateCinema( + [FromBody] CreateCinemaRequest request, + [FromServices] ISender sender, + CancellationToken cancellationToken) + { + var cinema = await sender.Send(new Commands.CreateCinema.CreateCinemaCommand + { + Name = request.Name + }, cancellationToken); + + return TypedResults.Created($"/api/cinemas/{cinema.Id}", cinema); + } + } + ``` + + Register `[JsonSerializable(typeof(Cinema))]`, `[JsonSerializable(typeof(Page))]` and `[JsonSerializable(typeof(CreateCinemaRequest))]` on `AppJsonSerializerContext.cs`, add the `"Cinemas"` tag in `OpenApi/DocumentInfoTransformer.cs`, and call `_ = app.MapCinemaEndpoints();` in `Extensions/WebApplicationExtensions.cs`. (If you created the solution with `--include-auth`, chain `.RequireAuthorization()` on `app.MapGroup("/api/cinemas")` too.) + +5. **Seed and test the new slice** (`sample-data`, `unit-testing` and `integration-testing` in `.agents/skills`). + + Add a handful of `Cinema` rows to `MovieReviewsDbContextExtensions.AddData` in `src/Infrastructure/Databases/MovieReviews/Extensions/MovieReviewsDbContextExtensions.cs` and `MovieReviewsCollectionData.AddTestData` in `tests/Infrastructure.Tests.Integration/Databases/MovieReviews/MovieReviewsCollectionData.cs`, then add unit and integration tests for `GetCinemas`, `CreateCinema`, `ICinemasRepository` and `CinemaEndpoints` mirroring the existing tests alongside them. Run `dotnet test` to verify both domains run side by side. + +6. **Clear out the sample Movies, Authors and Reviews** (`sample-data`, `database-migrations` and `coverage` in `.agents/skills`). + + Now that `Cinemas` exercises every layer, remove the sample vertical slices: + + - **Application:** Delete `src/Application/Authors/`, `src/Application/Movies/` and `src/Application/Reviews/`, and remove `Author`, `Movie` and `Review` from `EntityType.cs`. + - **Infrastructure:** Delete `Author.cs`, `Movie.cs` and `Review.cs` from `Models/` and `Configuration/`. Remove their `DbSet` properties from `MovieReviewsDbContext.cs`, their mappings from `MovieReviewsMapper.cs`, their methods and interfaces from `EntityFrameworkMovieReviewsRepository.cs`, their registrations from `DependencyInjection.cs`, and their seed blocks from `MovieReviewsDbContextExtensions.cs`. + - **Migrations:** Replace the migrations in `src/Infrastructure/Databases/MovieReviews/Migrations/` with a fresh initial migration for your schema: + + ```bash + rm src/Infrastructure/Databases/MovieReviews/Migrations/*.cs + dotnet ef migrations add InitialCreate \ + --project ./src/Infrastructure \ + --output-dir Databases/MovieReviews/Migrations + ``` + + Add `// ` at line 1 of the new `_InitialCreate.cs` file. + - **Presentation:** Delete `AuthorEndpoints.cs`, `MovieEndpoints.cs`, `ReviewEndpoints.cs` and `CreateReviewRequest.cs`. Remove their serializer attributes from `AppJsonSerializerContext.cs`, their tags from `DocumentInfoTransformer.cs`, their `Map*Endpoints()` calls from `WebApplicationExtensions.cs`, and update `src/Presentation/Presentation.http` to call `/api/cinemas`. + - **Tests:** Delete the `Authors`, `Movies` and `Reviews` test folders across all four test projects, remove their seed blocks from `MovieReviewsCollectionData.cs`, and point the shared plumbing tests (`LoggingBehaviourTests`, `ValidationBehaviourTests`, `NotFoundExceptionTests`, `DependencyInjectionTests`, `MovieReviewsConfigurationTests`, `OpenApiDocumentTests`, `TelemetryTests`, plus `AuthenticationTests` if you used `--include-auth`) at `Cinema`, `GetCinemasQuery` and `/api/cinemas`. + +7. **Verify format, build and the 95% coverage gate** (`coverage` in `.agents/skills`). + + ```bash + dotnet format --verify-no-changes + dotnet test -m:1 \ + -p:CollectCoverage=true -p:CoverletOutput=../results/ -p:MergeWith="../results/coverage.json" \ + -p:CoverletOutputFormat=opencover%2cjson \ + -p:ExcludeByFile="**/Program.cs%2c**/Migrations/*.cs%2c**/obj/**/*.cs" \ + -p:ExcludeByAttribute="GeneratedCodeAttribute" + ``` + + + + From 136cfcfbe6a577a112ca651cec56220e3e0bb4c1 Mon Sep 17 00:00:00 2001 From: Stephen Walsh Date: Sat, 7 Nov 2026 23:30:45 -0600 Subject: [PATCH 2/6] Bring the make-it-yours guide up to date with main --- astro.config.mjs | 15 ++++++++++----- src/content/docs/minimalapi/getting-started.mdx | 12 +++++++----- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/astro.config.mjs b/astro.config.mjs index 5967a0d..939296a 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,6 +1,7 @@ // @ts-check import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; +import starlightLinksValidator from 'starlight-links-validator'; import starlightLlmsTxt from 'starlight-llms-txt'; import starlightSidebarTopics from 'starlight-sidebar-topics'; import { brand, templates } from './src/data/templates.ts'; @@ -12,7 +13,7 @@ const sidebars = { { label: 'Start here', items: ['minimalapi/getting-started', 'minimalapi/guides/run-locally'] }, { label: 'Guides', - items: ['minimalapi/guides/make-it-yours', 'minimalapi/guides/add-an-endpoint', 'minimalapi/guides/database', 'minimalapi/guides/secure-the-api', 'minimalapi/guides/ship-the-container', 'minimalapi/guides/set-up-ci'], + items: ['minimalapi/guides/make-it-yours', 'minimalapi/guides/add-an-endpoint', 'minimalapi/guides/test-your-changes', 'minimalapi/guides/database', 'minimalapi/guides/secure-the-api', 'minimalapi/guides/ship-the-container', 'minimalapi/guides/set-up-ci', 'minimalapi/guides/work-with-a-coding-agent'], }, { label: 'Reference', items: [{ autogenerate: { directory: 'minimalapi/reference' } }] }, { @@ -24,7 +25,7 @@ const sidebars = { { label: 'Start here', items: ['webapi/getting-started', 'webapi/guides/run-locally'] }, { label: 'Guides', - items: ['webapi/guides/add-an-endpoint', 'webapi/guides/database', 'webapi/guides/secure-the-api', 'webapi/guides/ship-the-container', 'webapi/guides/set-up-ci'], + items: ['webapi/guides/add-an-endpoint', 'webapi/guides/test-your-changes', 'webapi/guides/database', 'webapi/guides/secure-the-api', 'webapi/guides/ship-the-container', 'webapi/guides/set-up-ci', 'webapi/guides/work-with-a-coding-agent'], }, { label: 'Reference', items: [{ autogenerate: { directory: 'webapi/reference' } }] }, { @@ -36,7 +37,7 @@ const sidebars = { { label: 'Start here', items: ['graphql/getting-started', 'graphql/guides/run-locally'] }, { label: 'Guides', - items: ['graphql/guides/add-a-field', 'graphql/guides/database', 'graphql/guides/secure-the-api', 'graphql/guides/ship-the-container', 'graphql/guides/set-up-ci'], + items: ['graphql/guides/add-a-field', 'graphql/guides/test-your-changes', 'graphql/guides/database', 'graphql/guides/secure-the-api', 'graphql/guides/ship-the-container', 'graphql/guides/set-up-ci', 'graphql/guides/work-with-a-coding-agent'], }, { label: 'Reference', items: [{ autogenerate: { directory: 'graphql/reference' } }] }, { @@ -48,7 +49,7 @@ const sidebars = { { label: 'Start here', items: ['mcp/getting-started', 'mcp/guides/run-locally'] }, { label: 'Guides', - items: ['mcp/guides/connect-a-client', 'mcp/guides/add-a-tool', 'mcp/guides/database', 'mcp/guides/secure-the-server', 'mcp/guides/ship-the-container', 'mcp/guides/set-up-ci'], + items: ['mcp/guides/connect-a-client', 'mcp/guides/add-a-tool', 'mcp/guides/test-your-changes', 'mcp/guides/database', 'mcp/guides/secure-the-server', 'mcp/guides/ship-the-container', 'mcp/guides/set-up-ci', 'mcp/guides/work-with-a-coding-agent'], }, { label: 'Reference', items: [{ autogenerate: { directory: 'mcp/reference' } }] }, { @@ -60,7 +61,7 @@ const sidebars = { { label: 'Start here', items: ['lambda/getting-started', 'lambda/guides/run-locally'] }, { label: 'Guides', - items: ['lambda/guides/add-a-function', 'lambda/guides/database', 'lambda/guides/deploy-to-aws', 'lambda/guides/secure-the-function', 'lambda/guides/set-up-ci'], + items: ['lambda/guides/add-a-function', 'lambda/guides/test-your-changes', 'lambda/guides/database', 'lambda/guides/deploy-to-aws', 'lambda/guides/secure-the-function', 'lambda/guides/set-up-ci', 'lambda/guides/work-with-a-coding-agent'], }, { label: 'Reference', items: [{ autogenerate: { directory: 'lambda/reference' } }] }, { @@ -125,6 +126,10 @@ export default defineConfig({ items: sidebars[t.id], })), ), + starlightLinksValidator({ + errorOnLocalLinks: false, + exclude: ['/', '/#*'], + }), starlightLlmsTxt({ projectName: 'Lean Mean Clean Apps', description: diff --git a/src/content/docs/minimalapi/getting-started.mdx b/src/content/docs/minimalapi/getting-started.mdx index 16ee716..cbc833a 100644 --- a/src/content/docs/minimalapi/getting-started.mdx +++ b/src/content/docs/minimalapi/getting-started.mdx @@ -91,12 +91,14 @@ You need the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0). [ +Hit a snag? Check [Troubleshooting](/minimalapi/reference/troubleshooting/). + ## Where to next - - - - - + + + + + From 27a04653bc5e90aef929bfdbebc9904ce74cd030 Mon Sep 17 00:00:00 2001 From: Stephen Walsh <868017+stphnwlsh@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:51:50 +0000 Subject: [PATCH 3/6] Rewrite the Minimal API make-it-yours guide from a tested solution --- .../docs/minimalapi/guides/make-it-yours.mdx | 401 ++++++++++++------ 1 file changed, 280 insertions(+), 121 deletions(-) diff --git a/src/content/docs/minimalapi/guides/make-it-yours.mdx b/src/content/docs/minimalapi/guides/make-it-yours.mdx index 1c44cf7..f6bdc2a 100644 --- a/src/content/docs/minimalapi/guides/make-it-yours.mdx +++ b/src/content/docs/minimalapi/guides/make-it-yours.mdx @@ -1,35 +1,51 @@ --- title: "Make it yours" -description: "Add your own entity, migration, use cases and endpoints, then clear out the sample movies, authors and reviews without dropping below 95% coverage." +description: "Add your own entity end to end, then clear out the sample movies, authors and reviews without dropping below the 95% coverage gate." sidebar: order: 2 --- -import { Steps, Aside } from '@astrojs/starlight/components'; +import { Steps, Aside, FileTree } from '@astrojs/starlight/components'; -The first thing anyone wants to do after `dotnet new` is evict *Deep Space Gardeners* and put their own domain in its place. If you delete `Movies`, `Authors` and `Reviews` straight away, half the shared plumbing tests fail to compile and the rest drop below the 95% coverage gate because the paging, validation, logging, OpenAPI and telemetry code suddenly has nothing to exercise it. +Sooner or later you'll want to evict *Deep Space Gardeners* and move your own domain in. The tempting move is to delete `Movies`, `Authors` and `Reviews` first. Don't. Half the tests stop compiling, and the shared plumbing (paging, validation, logging, OpenAPI and telemetry) suddenly has nothing to exercise it, so coverage falls through the floor. -The painless order is the other way round: build your first vertical slice alongside the sample domain, prove it works end to end, and then delete the movies. This guide adds a `Cinema` slice (`GET /api/cinemas` and `POST /api/cinemas`) and clears out the sample code at the end. +Go the other way round. Build your first slice beside the sample domain, prove it works end to end, then take the movies out. This guide adds cinemas, with `GET /api/cinemas` to list and search them and `POST /api/cinemas` to create one, and finishes with a solution that has no movies left in it. We ran every step on a fresh solution: it ends with 79 passing tests and 99.7% line coverage. +The code below assumes you named the solution `WorksOnMyMachine.Movies`. Swap in your own name wherever you see it. + -1. **Add the entity and use cases in `src/Application`** (`add-a-use-case` in `.agents/skills`). +1. **Add the entity and use cases to `src/Application`** (`add-a-use-case`). + + The entity, the repository interface, one query and one command, each with a validator. Handlers and validators are `public`, like the samples, so Mediator's source generator and the tests can see them. - Add `Cinema` to `src/Application/Common/Enums/EntityType.cs`, then create the domain record and repository interface in `src/Application/Cinemas/`: + + - src/Application/Cinemas/ + - Entities/Cinema.cs + - ICinemasRepository.cs + - Queries/GetCinemas/ + - GetCinemasQuery.cs + - GetCinemasHandler.cs + - GetCinemasValidator.cs + - Commands/CreateCinema/ + - CreateCinemaCommand.cs + - CreateCinemaHandler.cs + - CreateCinemaValidator.cs + ```csharp title="src/Application/Cinemas/Entities/Cinema.cs" namespace WorksOnMyMachine.Movies.Application.Cinemas.Entities; - public sealed record Cinema - { - public Guid Id { get; init; } - - public string Name { get; init; } = string.Empty; - } + /// + /// A cinema that shows movies. + /// + /// The unique identifier of the cinema. + /// The name of the cinema. + public record Cinema(Guid Id, string Name); ``` ```csharp title="src/Application/Cinemas/ICinemasRepository.cs" @@ -40,13 +56,16 @@ The painless order is the other way round: build your first vertical slice along public interface ICinemasRepository { - public Task CreateCinema(string name, CancellationToken cancellationToken); + public Task> GetCinemas( + string? query, + PageRequest page, + CancellationToken cancellationToken); - public Task> GetCinemas(PageRequest page, string? searchTerm, CancellationToken cancellationToken); + public Task CreateCinema(string name, CancellationToken cancellationToken); } ``` - Add a paged query in `src/Application/Cinemas/Queries/GetCinemas/` so `Common/Paging` stays covered when the sample queries go: + The query reuses the shared paging and search rules, so `Common/Paging` stays covered once the sample queries are gone. ```csharp title="src/Application/Cinemas/Queries/GetCinemas/GetCinemasQuery.cs" namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemas; @@ -55,12 +74,18 @@ The painless order is the other way round: build your first vertical slice along using Entities; using Mediator; - public sealed record GetCinemasQuery : IRequest> + /// + /// Lists cinemas one page at a time, optionally only those whose name contains a search term. + /// + public class GetCinemasQuery : IRequest> { - public string? SearchTerm { get; init; } + /// The words to look for in the name, ignoring case. Leave empty to list every cinema. + public string? Query { get; init; } + /// The number of results to skip, 0 for the first page. public int Offset { get; init; } + /// The largest number of results to return, from 1 to 50. public int Limit { get; init; } = Paging.DefaultLimit; } ``` @@ -72,14 +97,11 @@ The painless order is the other way round: build your first vertical slice along using Entities; using Mediator; - internal sealed class GetCinemasHandler(ICinemasRepository repository) : IRequestHandler> + public class GetCinemasHandler(ICinemasRepository repository) : IRequestHandler> { public async ValueTask> Handle(GetCinemasQuery request, CancellationToken cancellationToken) { - return await repository.GetCinemas( - new PageRequest(request.Offset, request.Limit), - request.SearchTerm, - cancellationToken); + return await repository.GetCinemas(request.Query, new PageRequest(request.Offset, request.Limit), cancellationToken); } } ``` @@ -90,26 +112,24 @@ The painless order is the other way round: build your first vertical slice along using Common.Paging; using FluentValidation; - public sealed class GetCinemasValidator : AbstractValidator + public class GetCinemasValidator : AbstractValidator { public GetCinemasValidator() { + _ = this.RuleFor(r => r.Query).ValidSearchTerm(); _ = this.RuleFor(r => r.Offset).ValidOffset(); _ = this.RuleFor(r => r.Limit).ValidLimit(); - _ = this.RuleFor(r => r.SearchTerm).ValidSearchTerm(); } } ``` - Add a command in `src/Application/Cinemas/Commands/CreateCinema/`: - ```csharp title="src/Application/Cinemas/Commands/CreateCinema/CreateCinemaCommand.cs" namespace WorksOnMyMachine.Movies.Application.Cinemas.Commands.CreateCinema; using Entities; using Mediator; - public sealed record CreateCinemaCommand : IRequest + public class CreateCinemaCommand : IRequest { public string Name { get; init; } = string.Empty; } @@ -121,7 +141,7 @@ The painless order is the other way round: build your first vertical slice along using Entities; using Mediator; - internal sealed class CreateCinemaHandler(ICinemasRepository repository) : IRequestHandler + public class CreateCinemaHandler(ICinemasRepository repository) : IRequestHandler { public async ValueTask Handle(CreateCinemaCommand request, CancellationToken cancellationToken) { @@ -135,131 +155,166 @@ The painless order is the other way round: build your first vertical slice along using FluentValidation; - public sealed class CreateCinemaValidator : AbstractValidator + public class CreateCinemaValidator : AbstractValidator { public CreateCinemaValidator() { - _ = this.RuleFor(r => r.Name) - .NotEmpty() - .MaximumLength(100); + _ = this.RuleFor(r => r.Name).NotEmpty().WithMessage("A cinema needs a name."); + _ = this.RuleFor(r => r.Name).MaximumLength(100).WithMessage("A cinema name can't be longer than 100 characters."); } } ``` -2. **Persist and map it in `src/Infrastructure`** (`data-access-ef-core` and `mapping-with-mapperly` in `.agents/skills`). +2. **Store and map it in `src/Infrastructure`** (`data-access-ef-core` and `mapping-with-mapperly`). - Add the EF Core model and configuration under `src/Infrastructure/Databases/MovieReviews/`: + Add the database model and its configuration beside the sample ones. Every model inherits `Entity`, which brings the id and the created and modified dates, and every configuration inherits `EntityConfiguration`, which sets them up. ```csharp title="src/Infrastructure/Databases/MovieReviews/Models/Cinema.cs" namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Models; - internal sealed record Cinema : BaseEntity + using System.Diagnostics.CodeAnalysis; + + [ExcludeFromCodeCoverage] + internal record Cinema : Entity { - public string Name { get; set; } = string.Empty; + public required string Name { get; init; } } ``` ```csharp title="src/Infrastructure/Databases/MovieReviews/Configuration/CinemaConfiguration.cs" namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Configuration; + using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using Models; - internal sealed class CinemaConfiguration : BaseConfiguration + internal class CinemaConfiguration : EntityConfiguration { public override void Configure(EntityTypeBuilder builder) { base.Configure(builder); - _ = builder.Property(e => e.Name) - .HasMaxLength(100) - .IsRequired(); + _ = builder.Property(c => c.Name).HasMaxLength(100).UseCollation(Collations.NoCase); + _ = builder.HasIndex(c => c.Name); } } ``` - Add `internal DbSet Cinemas { get; set; }` to `MovieReviewsDbContext.cs`, add the Mapperly method to `Mapping/MovieReviewsMapper.cs`: + Then wire it in: - ```csharp title="src/Infrastructure/Databases/MovieReviews/Mapping/MovieReviewsMapper.cs" - using AppCinema = Application.Cinemas.Entities.Cinema; - using InfraCinema = Models.Cinema; - ``` + - **`MovieReviewsDbContext.cs`:** add `public DbSet Cinemas => this.Set();` beside the other sets. + - **`Mapping/MovieReviewsMapper.cs`:** add `using AppCinema = Application.Cinemas.Entities.Cinema;` and `using InfraCinema = Models.Cinema;` with the other aliases, and this method: - ```csharp title="src/Infrastructure/Databases/MovieReviews/Mapping/MovieReviewsMapper.cs" - [MapperIgnoreSource(nameof(InfraCinema.DateCreated))] - [MapperIgnoreSource(nameof(InfraCinema.DateModified))] - internal partial AppCinema ToDomain(InfraCinema cinema); - ``` + ```csharp + [MapperIgnoreSource(nameof(InfraCinema.DateCreated))] + [MapperIgnoreSource(nameof(InfraCinema.DateModified))] + public partial AppCinema ToDomain(InfraCinema cinema); + ``` - Implement `ICinemasRepository` on `EntityFrameworkMovieReviewsRepository.cs` and register it in `src/Infrastructure/DependencyInjection.cs`: + - **`EntityFrameworkMovieReviewsRepository.cs`:** add `using Application.Cinemas;` and `using ApplicationCinema = Application.Cinemas.Entities.Cinema;`, add `ICinemasRepository` to the interfaces the class implements, and add a `Cinemas` region. The search reuses the repository's own `ContainsPattern` and `EscapeCharacter`, so a `%` or `_` in a search term matches itself, the same as for movies. - ```csharp title="src/Infrastructure/Databases/MovieReviews/EntityFrameworkMovieReviewsRepository.cs" - public async Task CreateCinema(string name, CancellationToken cancellationToken) - { - var utcNow = this.timeProvider.GetUtcNow().UtcDateTime; - var cinema = new Cinema - { - Name = name, - DateCreated = utcNow, - DateModified = utcNow - }; + ```csharp + #region Cinemas - _ = await this.context.Cinemas.AddAsync(cinema, cancellationToken); - _ = await this.context.SaveChangesAsync(cancellationToken); + public async Task> GetCinemas( + string? query, + PageRequest page, + CancellationToken cancellationToken) + { + var cinemas = this.context.Cinemas.AsQueryable(); - return this.mapper.ToDomain(cinema); - } + if (!string.IsNullOrWhiteSpace(query)) + { + var pattern = ContainsPattern(query); - public async Task> GetCinemas(PageRequest page, string? searchTerm, CancellationToken cancellationToken) - { - IQueryable cinemas = this.context.Cinemas.AsNoTracking(); + cinemas = cinemas.Where(c => EF.Functions.Like(c.Name, pattern, EscapeCharacter)); + } - if (!string.IsNullOrWhiteSpace(searchTerm)) - { - var term = searchTerm.Trim(); - cinemas = cinemas.Where(c => EF.Functions.Like(c.Name, $"%{term}%")); - } + var totalCount = await cinemas.CountAsync(cancellationToken); - var totalCount = await cinemas.CountAsync(cancellationToken); - var items = await cinemas - .OrderBy(c => c.Name) - .ThenBy(c => c.Id) - .Skip(page.Offset) - .Take(page.Limit) - .Select(c => this.mapper.ToDomain(c)) - .ToListAsync(cancellationToken); + var result = await cinemas + .OrderBy(c => c.Name) + .ThenBy(c => c.Id) + .Skip(page.Offset) + .Take(page.Limit) + .ToListAsync(cancellationToken); - return new Page(items, page.Offset, page.Limit, totalCount); - } - ``` + return new([.. result.Select(this.mapper.ToDomain)], page.Offset, page.Limit, totalCount); + } - ```csharp title="src/Infrastructure/DependencyInjection.cs" - _ = services.AddScoped(sp => sp.GetRequiredService()); - ``` + public async Task CreateCinema(string name, CancellationToken cancellationToken) + { + var now = this.timeProvider.GetUtcNow().UtcDateTime; + + var cinema = new Cinema + { + Name = name, + DateCreated = now, + DateModified = now + }; + + _ = this.context.Add(cinema); + + _ = await this.context.SaveChangesAsync(cancellationToken); + + return this.mapper.ToDomain(cinema); + } + + #endregion Cinemas + ``` -3. **Create the migration** (`database-migrations` in `.agents/skills`). + - **`src/Infrastructure/DependencyInjection.cs`:** add `using Application.Cinemas;` and register the interface against the same repository, like the others: + + ```csharp + _ = services.AddScoped(x => + x.GetRequiredService()); + ``` + + - **`Extensions/MovieReviewsDbContextExtensions.cs`** (`sample-data`): give `local` and `docker` some cinemas to look at. Add this to `AddData` before `SaveChanges`: + + ```csharp + var cinemas = new Faker() + .UseSeed(Seed) + .RuleFor(c => c.Id, f => f.Random.Guid()) + .RuleFor(c => c.Name, f => $"{f.Address.City()} Picture House") + .RuleFor(c => c.DateCreated, f => f.Date.Past(refDate: ReferenceDate)) + .RuleFor(c => c.DateModified, f => f.Date.Past(refDate: ReferenceDate)) + .Generate(5); + + context.AddRange(cinemas); + ``` + +3. **Add a migration** (`database-migrations`). ```bash dotnet tool install --global dotnet-ef dotnet ef migrations add AddCinemas \ --project ./src/Infrastructure \ - --output-dir Databases/MovieReviews/Migrations + --output-dir Databases/MovieReviews/Migrations \ + --namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Migrations + sed -i '1s/^\xEF\xBB\xBF//' src/Infrastructure/Databases/MovieReviews/Migrations/*.cs ``` - Put `// ` at the top of the new `_AddCinemas.cs` file (EF Core already puts it on `.Designer.cs`) so the style analysers skip generated migration code. + Without `--namespace` the migration lands in a namespace of its own. The `sed` strips the byte order mark the tool adds. On macOS, use `sed -i ''`. Then make `// ` the first line of the new `_AddCinemas.cs`, as it is in the other migrations, so the analysers leave generated code alone. -4. **Expose the endpoints in `src/Presentation`** (`expose-a-use-case` in `.agents/skills`). +4. **Put it on endpoints in `src/Presentation`** (`expose-a-use-case` and `xml-docs-and-openapi`). - Add a request DTO in `src/Presentation/Requests/CreateCinemaRequest.cs` and the endpoint group in `src/Presentation/Endpoints/CinemaEndpoints.cs`: + The XML comments aren't decoration. They become the OpenAPI description, and a test fails if an operation or schema has none. ```csharp title="src/Presentation/Requests/CreateCinemaRequest.cs" namespace WorksOnMyMachine.Movies.Presentation.Requests; /// - /// The details of a new Cinema to create. + /// The details needed to create a cinema. /// - /// The name of the cinema. - public sealed record CreateCinemaRequest(string Name); + public class CreateCinemaRequest + { + /// + /// The name of the cinema, up to 100 characters. + /// + /// The Astor + public string Name { get; init; } = string.Empty; + } ``` ```csharp title="src/Presentation/Endpoints/CinemaEndpoints.cs" @@ -267,16 +322,18 @@ The painless order is the other way round: build your first vertical slice along using Application.Common.Paging; using Mediator; + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; - using Requests; + using Presentation.Requests; using Commands = Application.Cinemas.Commands; using Entities = Application.Cinemas.Entities; using Queries = Application.Cinemas.Queries; - internal static class CinemaEndpoints + public static class CinemaEndpoints { - internal static WebApplication MapCinemaEndpoints(this WebApplication app) + public static WebApplication MapCinemaEndpoints(this WebApplication app) { var root = app.MapGroup("/api/cinemas") .WithTags("Cinemas"); @@ -289,7 +346,6 @@ The painless order is the other way round: build your first vertical slice along _ = root.MapPost("/", CreateCinema) .WithName(nameof(CreateCinema)) - .Accepts("application/json") .Produces(StatusCodes.Status201Created) .ProducesValidationProblem() .ProducesProblem(StatusCodes.Status500InternalServerError); @@ -298,18 +354,32 @@ The painless order is the other way round: build your first vertical slice along } /// - /// Search Cinemas + /// List Cinemas /// + /// + /// Returns one page of cinemas, ordered by name. Add a search term to return only the cinemas whose name + /// contains it, ignoring case. To fetch the next page, add the limit to the offset. + /// + /// Sample request: + /// + /// GET /api/cinemas?query=astor&offset=0&limit=10 + /// + /// The words to look for in the name, ignoring case. Leave it out to list every cinema. + /// The number of cinemas to skip, 0 for the first page. + /// The largest number of cinemas to return, from 1 to 50. + /// One page of Cinemas. + /// The search term, offset or limit is not valid. + /// An unexpected error occurred. public static async Task>> GetCinemas( + [FromQuery] string? query, [FromServices] ISender sender, CancellationToken cancellationToken, - [FromQuery] string? query = null, [FromQuery] int offset = 0, [FromQuery] int limit = Paging.DefaultLimit) { return TypedResults.Ok(await sender.Send(new Queries.GetCinemas.GetCinemasQuery { - SearchTerm = query, + Query = query, Offset = offset, Limit = limit }, cancellationToken)); @@ -318,49 +388,136 @@ The painless order is the other way round: build your first vertical slice along /// /// Create a Cinema /// + /// + /// Creates a cinema. The name can't be empty or longer than 100 characters. + /// + /// Sample request: + /// + /// POST /api/cinemas + /// { + /// "name": "The Astor" + /// } + /// + /// The cinema to create. + /// The Cinema that was created. + /// The request is not valid, for example the name is empty. + /// An unexpected error occurred. public static async Task> CreateCinema( [FromBody] CreateCinemaRequest request, [FromServices] ISender sender, CancellationToken cancellationToken) { - var cinema = await sender.Send(new Commands.CreateCinema.CreateCinemaCommand + var response = await sender.Send(new Commands.CreateCinema.CreateCinemaCommand { Name = request.Name }, cancellationToken); - return TypedResults.Created($"/api/cinemas/{cinema.Id}", cinema); + return TypedResults.Created((string?)null, response); } } ``` - Register `[JsonSerializable(typeof(Cinema))]`, `[JsonSerializable(typeof(Page))]` and `[JsonSerializable(typeof(CreateCinemaRequest))]` on `AppJsonSerializerContext.cs`, add the `"Cinemas"` tag in `OpenApi/DocumentInfoTransformer.cs`, and call `_ = app.MapCinemaEndpoints();` in `Extensions/WebApplicationExtensions.cs`. (If you created the solution with `--include-auth`, chain `.RequireAuthorization()` on `app.MapGroup("/api/cinemas")` too.) + There's no endpoint that gets one cinema by its id yet, so the 201 has no `Location` header. Add a `GetCinemaById` and switch to `TypedResults.CreatedAtRoute`, as `CreateReview` does, when you need one. + + Then register it: + + - **`Serialization/AppJsonSerializerContext.cs`** (`json-serialization`): add `using Application.Cinemas.Entities;`, then `[JsonSerializable(typeof(Cinema))]`, `[JsonSerializable(typeof(Page))]` and `[JsonSerializable(typeof(CreateCinemaRequest))]`. + - **`OpenApi/DocumentInfoTransformer.cs`:** add `CreateTag("Cinemas", "Lookup all Cinemas, search them by name, or create new ones"),` to the tags. + - **`Extensions/WebApplicationExtensions.cs`:** add `_ = app.MapCinemaEndpoints();` beside the other `Map*Endpoints()` calls. + - **`--include-auth`:** if you created the solution with it, add `_ = root.RequireAuthorization();` after `WithTags`, as the other endpoint groups do. + - **`Presentation.http`:** add a request for each endpoint, including one that fails validation. + +5. **Test the new slice** (`test-design`, `unit-tests` and `integration-tests`). + + Each new test has a sample test that already does the same job for movies or reviews. Copy it and rename it, and you have the shape, the names and the house style without having to think about them. + + | New test | Copy from | + | --- | --- | + | `tests/Application.Tests.Unit/Cinemas/Queries/GetCinemas/GetCinemasHandlerTests.cs` | `Movies/Queries/GetMovies/GetMoviesHandlerTests.cs` | + | `tests/Application.Tests.Unit/Cinemas/Queries/GetCinemas/GetCinemasValidatorTests.cs` | `Movies/Queries/GetMovies/GetMoviesValidatorTests.cs` | + | `tests/Application.Tests.Unit/Cinemas/Commands/CreateCinema/CreateCinemaHandlerTests.cs` | `Reviews/Commands/CreateReview/CreateReviewHandlerTests.cs` | + | `tests/Application.Tests.Unit/Cinemas/Commands/CreateCinema/CreateCinemaValidatorTests.cs` | `Reviews/Commands/CreateReview/CreateReviewValidatorTests.cs` | + | A `Cinemas` region in `tests/Infrastructure.Tests.Integration/Databases/MovieReviews/EntityFrameworkMovieReviewsRepositoryTests.cs` | The `Movies` region: search, paging and create | + | `tests/Presentation.Tests.Unit/Endpoints/CinemaEndpointTests.cs` | `MovieEndpointTests.cs` and `ReviewEndpointTests.cs` | + | `tests/Presentation.Tests.Integration/Endpoints/CinemaEndpointTests.cs` | `MovieEndpointTests.cs` and `ReviewEndpointTests.cs` | + + The repository tests need some cinemas. Add three to `AddTestData` in `tests/Infrastructure.Tests.Integration/Databases/MovieReviews/Extensions/MovieReviewsCollectionData.cs`, named the same way as the sample rows: + + ```csharp + List cinemas = + [ + new() { Id = Guid.NewGuid(), Name = "One" }, + new() { Id = Guid.NewGuid(), Name = "Two" }, + new() { Id = Guid.NewGuid(), Name = "Three" } + ]; + + context.Cinemas.AddRange(cinemas); + ``` + + Two existing tests in `tests/Presentation.Tests.Integration/OpenApi/OpenApiDocumentTests.cs` fail as soon as the endpoints exist. They're doing their job: they pin down exactly what the API publishes. + + - **`GetOpenApiDocument_ShouldDescribe_Tags`:** add `"Cinemas"` to the expected tags. + - **`GetOpenApiDocument_ShouldDescribe_EveryOperation`:** add 2 to the operation count, one for each new endpoint. + - **`GetOpenApiDocument_ShouldDescribe_Schema`:** add `[InlineData("Cinema")]`, `[InlineData("PageOfCinema")]` and `[InlineData("CreateCinemaRequest")]`, so the new schemas have to be documented too. -5. **Seed and test the new slice** (`sample-data`, `unit-testing` and `integration-testing` in `.agents/skills`). + ```bash + dotnet build + dotnet test + dotnet format --verify-no-changes + ``` - Add a handful of `Cinema` rows to `MovieReviewsDbContextExtensions.AddData` in `src/Infrastructure/Databases/MovieReviews/Extensions/MovieReviewsDbContextExtensions.cs` and `MovieReviewsCollectionData.AddTestData` in `tests/Infrastructure.Tests.Integration/Databases/MovieReviews/MovieReviewsCollectionData.cs`, then add unit and integration tests for `GetCinemas`, `CreateCinema`, `ICinemasRepository` and `CinemaEndpoints` mirroring the existing tests alongside them. Run `dotnet test` to verify both domains run side by side. + Both domains now run side by side. Stop here if you're keeping the sample for reference, which is a perfectly respectable choice. -6. **Clear out the sample Movies, Authors and Reviews** (`sample-data`, `database-migrations` and `coverage` in `.agents/skills`). +6. **Take out the sample movies, authors and reviews** (`sample-data` and `database-migrations`). - Now that `Cinemas` exercises every layer, remove the sample vertical slices: + This step is mostly deleting, but the template's own tests use the sample as their test subject, so a few of them change instead of going. Work through it layer by layer and build as you go. The compiler finds what's left faster than any list. - - **Application:** Delete `src/Application/Authors/`, `src/Application/Movies/` and `src/Application/Reviews/`, and remove `Author`, `Movie` and `Review` from `EntityType.cs`. - - **Infrastructure:** Delete `Author.cs`, `Movie.cs` and `Review.cs` from `Models/` and `Configuration/`. Remove their `DbSet` properties from `MovieReviewsDbContext.cs`, their mappings from `MovieReviewsMapper.cs`, their methods and interfaces from `EntityFrameworkMovieReviewsRepository.cs`, their registrations from `DependencyInjection.cs`, and their seed blocks from `MovieReviewsDbContextExtensions.cs`. - - **Migrations:** Replace the migrations in `src/Infrastructure/Databases/MovieReviews/Migrations/` with a fresh initial migration for your schema: + - **Application:** delete `src/Application/Authors/`, `Movies/` and `Reviews/`. In `Common/Enums/EntityType.cs`, replace `Author`, `Movie` and `Review` with `Cinema`. `NotFoundException` uses it, and you'll want it the day you add `GetCinemaById`. + - **Infrastructure:** delete `Author.cs`, `Movie.cs` and `Review.cs` from `Models/`, and the matching `*Configuration.cs` files. Then remove from: + - `MovieReviewsDbContext.cs`: their three `DbSet` properties. + - `MovieReviewsMapper.cs`: their aliases and mapping methods. + - `EntityFrameworkMovieReviewsRepository.cs`: the `Authors`, `Movies`, `Reviews` and `Helpers` regions, their `using` lines and aliases, and their three interfaces. + - `DependencyInjection.cs`: their three registrations and `using` lines. + - `MovieReviewsDbContextExtensions.cs`: the titles, the review texts and the author, movie and review fakers. Change the guard at the top of `AddData` to `context.Cinemas.Any()`. + - **Presentation:** delete `AuthorEndpoints.cs`, `MovieEndpoints.cs`, `ReviewEndpoints.cs`, `CreateReviewRequest.cs` and `UpdateReviewRequest.cs`. Then remove from: + - `AppJsonSerializerContext.cs`: their `[JsonSerializable]` lines and `using` lines. + - `DocumentInfoTransformer.cs`: their tags. + - `WebApplicationExtensions.cs`: their `Map*Endpoints()` calls. + - `Presentation.http`: the sample ids and requests. + - **Migrations:** replace the history with one migration for your schema. Then add `// ` to its first line, as in step 3. ```bash rm src/Infrastructure/Databases/MovieReviews/Migrations/*.cs dotnet ef migrations add InitialCreate \ --project ./src/Infrastructure \ - --output-dir Databases/MovieReviews/Migrations + --output-dir Databases/MovieReviews/Migrations \ + --namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Migrations + sed -i '1s/^\xEF\xBB\xBF//' src/Infrastructure/Databases/MovieReviews/Migrations/*.cs ``` - Add `// ` at line 1 of the new `_InitialCreate.cs` file. - - **Presentation:** Delete `AuthorEndpoints.cs`, `MovieEndpoints.cs`, `ReviewEndpoints.cs` and `CreateReviewRequest.cs`. Remove their serializer attributes from `AppJsonSerializerContext.cs`, their tags from `DocumentInfoTransformer.cs`, their `Map*Endpoints()` calls from `WebApplicationExtensions.cs`, and update `src/Presentation/Presentation.http` to call `/api/cinemas`. - - **Tests:** Delete the `Authors`, `Movies` and `Reviews` test folders across all four test projects, remove their seed blocks from `MovieReviewsCollectionData.cs`, and point the shared plumbing tests (`LoggingBehaviourTests`, `ValidationBehaviourTests`, `NotFoundExceptionTests`, `DependencyInjectionTests`, `MovieReviewsConfigurationTests`, `OpenApiDocumentTests`, `TelemetryTests`, plus `AuthenticationTests` if you used `--include-auth`) at `Cinema`, `GetCinemasQuery` and `/api/cinemas`. + Delete `src/Presentation/movie-reviews.db` too, if you've run the API. It still holds the old tables, and the new migration history won't recognise it. + + - **Tests to delete:** + - `tests/Application.Tests.Unit/`: the `Authors/`, `Movies/` and `Reviews/` folders. + - `tests/Presentation.Tests.Unit/Endpoints/` and `tests/Presentation.Tests.Integration/Endpoints/`: `AuthorEndpointTests.cs`, `MovieEndpointTests.cs` and `ReviewEndpointTests.cs`. + - `EntityFrameworkMovieReviewsRepositoryTests.cs`: the `Authors`, `Movies` and `Reviews` regions. + - `MovieReviewsCollectionData.cs`: the author, movie and review rows. + - **Tests to change:** these test the template's plumbing, and they used the sample as their subject. Point them at cinemas. -7. **Verify format, build and the 95% coverage gate** (`coverage` in `.agents/skills`). + | Test | Change | + | --- | --- | + | `Common/Behaviours/LoggingBehaviourTests.cs` and `ValidationBehaviourTests.cs` | Use `CreateCinemaCommand`, `CreateCinemaValidator` and `Cinema` in place of `GetAuthorByIdQuery`, its validator and `Author`. The invalid case is an empty `Name`, so the expected property is `Name`. The logged message names `CreateCinemaCommand`. | + | `Common/Exceptions/NotFoundExceptionTests.cs` | Use `EntityType.Cinema` and a `Cinema`. The message becomes "The Cinema with the supplied id was not found." | + | `MovieReviewsConfigurationTests.cs` | Check a cinema loads, ignoring case: look up `"one"` and expect `"One"`. | + | `DependencyInjectionTests.cs` | Expect `ICinemasRepository`, 5 seeded cinemas and none when seeding is off. Use `GetCinemas` for the concurrency and same-sample-data checks. | + | `Telemetry/TelemetryTests.cs` | Call `/api/cinemas?limit=5` and expect a `GetCinemasQuery` span. | + | `OpenApi/OpenApiDocumentTests.cs` | Expect the tags `Cinemas` and `Version`, 3 operations, and the schemas `Cinema`, `PageOfCinema`, `CreateCinemaRequest` and `VersionInfo`. | + | `AuthenticationTests.cs`, only with `--include-auth` | Point its requests at `/api/cinemas`. | + +7. **Check it the way CI will** (`coverage`). ```bash + dotnet build dotnet format --verify-no-changes dotnet test -m:1 \ -p:CollectCoverage=true -p:CoverletOutput=../results/ -p:MergeWith="../results/coverage.json" \ @@ -369,8 +526,10 @@ The painless order is the other way round: build your first vertical slice along -p:ExcludeByAttribute="GeneratedCodeAttribute" ``` + The last table it prints is the merged total. Ours came out at 99.7%. If yours is under 95%, `coverage` says what to do about it, and it isn't "lower the number". + - +## What's still called "movie reviews" + +The database plumbing keeps its sample name: the `Databases/MovieReviews` folder, `MovieReviewsDbContext`, `EntityFrameworkMovieReviewsRepository`, the `MovieReviews` connection string and the `movie-reviews.db` file. They all work as they are. Everything in `src/Infrastructure` is `internal`, so renaming them later is a refactor that never reaches Application or Presentation. Do it when the name starts to bother you, and change the connection string in every `appsettings.*.json` and in `docker-compose.yml` at the same time. From 7467d58d35e44fafa5e1a3bc2b3f4d0db422ab82 Mon Sep 17 00:00:00 2001 From: Stephen Walsh <868017+stphnwlsh@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:55:50 +0000 Subject: [PATCH 4/6] Move the regenerated migration snapshot back into place --- src/content/docs/minimalapi/guides/make-it-yours.mdx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/content/docs/minimalapi/guides/make-it-yours.mdx b/src/content/docs/minimalapi/guides/make-it-yours.mdx index f6bdc2a..0ab9967 100644 --- a/src/content/docs/minimalapi/guides/make-it-yours.mdx +++ b/src/content/docs/minimalapi/guides/make-it-yours.mdx @@ -9,7 +9,7 @@ import { Steps, Aside, FileTree } from '@astrojs/starlight/components'; Sooner or later you'll want to evict *Deep Space Gardeners* and move your own domain in. The tempting move is to delete `Movies`, `Authors` and `Reviews` first. Don't. Half the tests stop compiling, and the shared plumbing (paging, validation, logging, OpenAPI and telemetry) suddenly has nothing to exercise it, so coverage falls through the floor. -Go the other way round. Build your first slice beside the sample domain, prove it works end to end, then take the movies out. This guide adds cinemas, with `GET /api/cinemas` to list and search them and `POST /api/cinemas` to create one, and finishes with a solution that has no movies left in it. We ran every step on a fresh solution: it ends with 79 passing tests and 99.7% line coverage. +Go the other way round. Build your first slice beside the sample domain, prove it works end to end, then take the movies out. This guide adds cinemas, with `GET /api/cinemas` to list and search them and `POST /api/cinemas` to create one, and finishes with a solution that has no movies left in it. We ran every step on a fresh solution: it ends with 79 passing tests and 99.7% line coverage, and we ran it a second time from the page alone to be sure.