Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions .agents/skills/data-access-ef-core/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: data-access-ef-core
description: How the repository reads and writes the SQLite database with EF Core - scoped DbContext, no-tracking reads, SQL projections for lists, set-based updates and deletes, and safe LIKE searches. Use when adding or changing a repository method or a query.
description: How the repository reads and writes the SQLite database with EF Core - scoped DbContext, no tracking by default, SQL projections for lists, set-based updates and deletes, and safe LIKE searches. Use when adding or changing a repository method or a query.
metadata:
scope: shared
---
Expand All @@ -9,30 +9,33 @@ metadata:

## The rule

One repository, `EntityFrameworkMovieReviewsRepository` in `src/Infrastructure/Databases/MovieReviews/`, implements every repository interface from Application. It and `MovieReviewsDbContext` are scoped: one per request, never shared between threads. Reads don't track. Lists are projected in SQL. Updates and deletes are set-based. The repository returns Application entities, never database models.
One repository, `EntityFrameworkMovieReviewsRepository` in `src/Infrastructure/Databases/MovieReviews/`, implements every repository interface from Application. It and `MovieReviewsDbContext` are scoped: one per request, never shared between threads. The context doesn't track queries (`QueryTrackingBehavior.NoTracking` in `AddInfrastructure`), because nothing loads an entity to change it. Lists are projected in SQL. Updates and deletes are set-based and are their own existence check. The repository returns Application entities, never database models.

## Steps

1. Add the method to the Application interface first (`IMoviesRepository` and friends), taking a `CancellationToken` last.
2. Implement it in the matching `#region` of the repository, as `virtual async Task<...>` like its neighbours.
3. Reads: start from `this.context.<Set>.AsNoTracking()`, filter, then either
2. Implement it in the matching `#region` of the repository, as `public virtual async Task<...>` like every other method.
3. Reads: start from `this.context.<Set>`, filter, then either
- **a list**: count, order (ending with the id), skip, take, and `Select` the summary fields so SQL does the counting and averaging (`paging`), or
- **one entity**: `Include` what the entity needs, `FirstOrDefaultAsync`, then map with `this.mapper.ToDomain(...)` (`mapping-with-mapperly`).
4. Writes:
- **create**: build the model, set `DateCreated` and `DateModified` from `this.timeProvider.GetUtcNow().UtcDateTime`, `Add`, `SaveChangesAsync`, then read it back with its includes.
- **update**: `Where(...).ExecuteUpdateAsync(setters => ...)`, always setting `DateModified`.
- **delete**: `Where(...).ExecuteDeleteAsync(...)`.
- **update**: `Where(...).ExecuteUpdateAsync(setters => ...)`, always setting `DateModified`. No rows changed means not found, so return `null`; otherwise read the entity back and return it.
- **delete**: `Where(...).ExecuteDeleteAsync(...)`, returning whether a row was deleted.
- Don't check first. The handler turns `null` or `false` into `NotFoundException` (`errors-and-exceptions`).
- New rows get a version 7 GUID from `Version7GuidValueGenerator`, set up for every model in `EntityConfiguration<T>`. Each model needs its own configuration class, even an empty one, or it gets random GUIDs.
5. Text search uses `EF.Functions.Like` with the `ContainsPattern` helper, which escapes `%`, `_` and `\`.
6. Add repository tests (`integration-tests`).

## Example

```csharp
// src/Infrastructure/Databases/MovieReviews/EntityFrameworkMovieReviewsRepository.cs
public async Task<bool> UpdateReview(Guid id, int stars, CancellationToken cancellationToken)
public virtual async Task<ApplicationReview?> UpdateReview(Guid id, int stars, CancellationToken cancellationToken)
{
var now = this.timeProvider.GetUtcNow().UtcDateTime;

// The update is the existence check: no rows changed means there's no such review.
var updated = await this.context.Reviews
.Where(r => r.Id == id)
.ExecuteUpdateAsync(
Expand All @@ -41,7 +44,7 @@ public async Task<bool> UpdateReview(Guid id, int stars, CancellationToken cance
.SetProperty(r => r.DateModified, now),
cancellationToken);

return updated > 0;
return updated == 0 ? null : await this.GetReviewById(id, cancellationToken);
}
```

Expand All @@ -61,6 +64,8 @@ if (!string.IsNullOrWhiteSpace(query))
- Use `DateTime.UtcNow`. Use the injected `TimeProvider` so tests can fix the clock.
- `Include` children on a list query to count them.
- Load an entity, change it and `SaveChanges` to update one column. Use `ExecuteUpdateAsync`.
- Add `AsNoTracking()` to a query. It's already the default.
- Call an `Exists` method before an update or delete.
- Return a database model or `IQueryable` from the repository.

## Check
Expand Down
12 changes: 7 additions & 5 deletions .agents/skills/database-migrations/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The schema comes only from migrations in `src/Infrastructure/Databases/MovieRevi

## Steps

1. Change the database model in `Models/` and its configuration in `Configuration/`. Relationships and keys are configured with the fluent API in an `EntityConfiguration<T>` subclass.
1. Change the database model in `Models/` and its configuration in `Configuration/`. Relationships, keys, indexes and collations are configured with the fluent API in an `EntityConfiguration<T>` subclass. Text people search or sort by uses `UseCollation(Collations.NoCase)`, and columns we sort or filter by get a `HasIndex`.
2. Install the tool once: `dotnet tool install --global dotnet-ef`.
3. Add the migration from the repository root:

Expand All @@ -25,15 +25,17 @@ The schema comes only from migrations in `src/Infrastructure/Databases/MovieRevi
```

4. Check where the files landed. If `MovieReviewsDbContextModelSnapshot.cs` was written somewhere else or in another namespace, move it into `Migrations/` and fix its namespace.
5. Strip the byte order mark the tool adds, so `dotnet format` and the diff stay clean:
5. Strip the byte order mark the tool adds, so `dotnet format` and the diff stay clean, and make sure the migration file starts with `// <auto-generated />` so the analysers skip it. The tool adds that line to the `.Designer.cs` file but not always to the migration itself:

``` bash
sed -i '1s/^\xEF\xBB\xBF//' src/Infrastructure/Databases/MovieReviews/Migrations/*.cs
```

6. Name the migration for what it does (`AddReviewComment`), in PascalCase.
7. Update `MovieReviewsDbContextExtensions.AddData` and the test data if the new column needs a value (`sample-data`).
8. Run every test project. Migrations are excluded from coverage.
6. Rename both files and the `[Migration]` attribute to `<yyyyMMdd>000000_<Name>`, so the id is the day it was written and not the second you ran the tool.

7. Name the migration for what it does (`AddReviewComment`), in PascalCase.
8. Update `MovieReviewsDbContextExtensions.AddData` and the test data if the new column needs a value (`sample-data`).
9. Check nothing is left over with `dotnet ef migrations has-pending-model-changes --project ./src/Infrastructure`, then run every test project. Migrations are excluded from coverage.

## Don't

Expand Down
15 changes: 13 additions & 2 deletions .agents/skills/errors-and-exceptions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,25 @@ The presentation layer maps each exception to its own shape in one place (in a w
## Steps

1. For a missing entity, use the helpers rather than `throw new`:
- `NotFoundException.ThrowIfNull(result, EntityType.Movie);` after a fetch.
- `NotFoundException.Throw(EntityType.Author);` after an existence check.
- `NotFoundException.ThrowIfNull(result, EntityType.Movie);` after a fetch, or after an update that returns the changed entity, or `null` when no row matched.
- `NotFoundException.Throw(EntityType.Review);` when a delete changed no rows.
- `NotFoundException.Throw(EntityType.Author);` after an existence check. Only check first when the write itself can't tell you which thing is missing, such as a new review's author and movie.
2. A new kind of entity needs a value in `EntityType` (`src/Application/Common/Enums/EntityType.cs`).
3. Test the not-found path in the handler tests with `Should.ThrowAsync<NotFoundException>`.
4. If you add a new exception type, map it in the presentation layer's error handler in the same change, and test the mapping. If the caller should see it as an answer rather than a fault, log it in `LoggingBehaviour` too.
5. Test that the handler logs an unexpected exception once, as `Error`, and logs nothing for the ones it maps (`unit-tests`).

## Example

```csharp
// src/Application/Reviews/Commands/UpdateReview/UpdateReviewHandler.cs
var review = await reviewsRepository.UpdateReview(request.Id, request.Stars, cancellationToken);

NotFoundException.ThrowIfNull(review, EntityType.Review);

return review;
```

```csharp
// src/Application/Reviews/Commands/CreateReview/CreateReviewHandler.cs
if (!await authorsRepository.AuthorExists(request.AuthorId, cancellationToken))
Expand All @@ -48,6 +58,7 @@ if (!await authorsRepository.AuthorExists(request.AuthorId, cancellationToken))
- Return `null`, `false` or an empty result to mean "not found" from a handler.
- Catch exceptions in a handler to turn them into something else.
- Introduce a `Result<T>` type. We use exceptions on purpose.
- Check that a row exists and then update or delete it. That's two trips and a race; let the write tell you.
- Put a stack trace, SQL or exception type name in a message.

## Check
Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/paging/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Lists return summaries (`MovieSummary`, `AuthorSummary`) worked out in SQL, not

1. Give the query `Offset` (default 0) and `Limit` (default `Paging.DefaultLimit`) properties, plus an optional `Query` search term if the list can be searched.
2. Validate them with `ValidOffset()`, `ValidLimit()` and `ValidSearchTerm()` (`validation`).
3. Have the repository return `(List<T> Items, int TotalCount)`. Count before you skip and take.
3. Have the repository return `(List<T> Items, int TotalCount)`; `Page<T>` exposes the items as an `IReadOnlyList<T>`. Count before you skip and take.
4. Order by something that makes a stable order, ending with the id, so a row never shows up on two pages.
5. Build the page in the handler: `new Page<T>(items, request.Offset, request.Limit, totalCount)`.

Expand Down
5 changes: 2 additions & 3 deletions .agents/skills/test-design/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public async Task Handle_ShouldThrowException_ReviewDoesNotExist()
// Arrange
var command = new UpdateReviewCommand { Id = Guid.NewGuid(), Stars = 5 };
var reviewsRepository = Substitute.For<IReviewsRepository>();
_ = reviewsRepository.ReviewExists(default, default).ReturnsForAnyArgs(false);
_ = reviewsRepository.UpdateReview(default, default, default).ReturnsForAnyArgs((Review?)null);
var handler = new UpdateReviewHandler(reviewsRepository);
using var tokenSource = new CancellationTokenSource();
var token = tokenSource.Token;
Expand All @@ -64,8 +64,7 @@ public async Task Handle_ShouldThrowException_ReviewDoesNotExist()

// Assert
exception.Message.ShouldBe("The Review with the supplied id was not found.");
_ = await reviewsRepository.Received(1).ReviewExists(command.Id, token);
_ = await reviewsRepository.DidNotReceiveWithAnyArgs().UpdateReview(default, default, default);
_ = await reviewsRepository.Received(1).UpdateReview(command.Id, command.Stars, token);
}
```

Expand Down
9 changes: 4 additions & 5 deletions .agents/skills/unit-tests/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Names read as `Method_Should<Outcome>_<Condition>`: `Handle_ShouldThrowException
```csharp
// tests/Application.Tests.Unit/Reviews/Commands/UpdateReview/UpdateReviewHandlerTests.cs
[Fact]
public async Task Handle_ShouldPassThrough_Command()
public async Task Handle_ShouldReturnUpdatedReview_ReviewExists()
{
// Arrange
var command = new UpdateReviewCommand
Expand All @@ -43,10 +43,10 @@ public async Task Handle_ShouldPassThrough_Command()
Stars = 1
};

var review = new Review(command.Id, command.Stars);
var reviewsRepository = Substitute.For<IReviewsRepository>();

_ = reviewsRepository.ReviewExists(default, default).ReturnsForAnyArgs(true);
_ = reviewsRepository.UpdateReview(default, default, default).ReturnsForAnyArgs(true);
_ = reviewsRepository.UpdateReview(default, default, default).ReturnsForAnyArgs(review);

var handler = new UpdateReviewHandler(reviewsRepository);
using var tokenSource = new CancellationTokenSource();
Expand All @@ -56,9 +56,8 @@ public async Task Handle_ShouldPassThrough_Command()
var result = await handler.Handle(command, token);

// Assert
result.ShouldBeTrue();
result.ShouldBe(review);

_ = await reviewsRepository.Received(1).ReviewExists(command.Id, token);
_ = await reviewsRepository.Received(1).UpdateReview(command.Id, command.Stars, token);
}
```
Expand Down
6 changes: 6 additions & 0 deletions src/Application/Application.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,10 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>

<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>$(AssemblyName).Tests.Unit</_Parameter1>
</AssemblyAttribute>
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
namespace CleanModelContextProtocol.Application.Authors.Queries.GetAuthorById;

using System.Threading;
using System.Threading.Tasks;
using Common.Enums;
using Common.Exceptions;
using Entities;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
namespace CleanModelContextProtocol.Application.Authors.Queries.GetAuthorById;

using System.ComponentModel.DataAnnotations;
using Entities;
using Mediator;

public class GetAuthorByIdQuery : IRequest<Author>
{
[Required]
public Guid Id { get; init; }
}
4 changes: 0 additions & 4 deletions src/Application/Common/Behaviours/LoggingBehaviour.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
namespace CleanModelContextProtocol.Application.Common.Behaviours;

using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Exceptions;
using FluentValidation;
using Mediator;
Expand Down
4 changes: 0 additions & 4 deletions src/Application/Common/Behaviours/ValidationBehaviour.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
namespace CleanModelContextProtocol.Application.Common.Behaviours;

using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentValidation;
using Mediator;

Expand Down
1 change: 0 additions & 1 deletion src/Application/Common/Exceptions/NotFoundException.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
namespace CleanModelContextProtocol.Application.Common.Exceptions;

using System;
using System.Diagnostics.CodeAnalysis;
using Enums;

Expand Down
2 changes: 1 addition & 1 deletion src/Application/Common/Paging/Page.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ namespace CleanModelContextProtocol.Application.Common.Paging;
/// <param name="Offset">The number of results skipped before this page.</param>
/// <param name="Limit">The largest number of results this page could hold.</param>
/// <param name="TotalCount">The number of results across every page.</param>
public record Page<T>(List<T> Items, int Offset, int Limit, int TotalCount);
public record Page<T>(IReadOnlyList<T> Items, int Offset, int Limit, int TotalCount);
1 change: 0 additions & 1 deletion src/Application/DependencyInjection.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
namespace CleanModelContextProtocol.Application;

using System;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Common.Behaviours;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
namespace CleanModelContextProtocol.Application.Movies.Queries.GetMovieById;

using System.Threading;
using System.Threading.Tasks;
using Common.Enums;
using Common.Exceptions;
using Entities;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
namespace CleanModelContextProtocol.Application.Movies.Queries.GetMovieById;

using System.ComponentModel.DataAnnotations;
using Entities;
using Mediator;

public class GetMovieByIdQuery : IRequest<Movie>
{
[Required]
public Guid Id { get; init; }
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
namespace CleanModelContextProtocol.Application.Reviews.Commands.CreateReview;

using System.Threading;
using System.Threading.Tasks;
using Authors;
using Common.Enums;
using Common.Exceptions;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ namespace CleanModelContextProtocol.Application.Reviews.Commands.DeleteReview;

using Mediator;

public class DeleteReviewCommand : IRequest<bool>
public class DeleteReviewCommand : IRequest
{
public Guid Id { get; init; }
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
namespace CleanModelContextProtocol.Application.Reviews.Commands.DeleteReview;

using System.Threading;
using System.Threading.Tasks;
using Common.Enums;
using Common.Exceptions;
using Mediator;

public class DeleteReviewHandler(IReviewsRepository repository) : IRequestHandler<DeleteReviewCommand, bool>
public class DeleteReviewHandler(IReviewsRepository repository) : IRequestHandler<DeleteReviewCommand>
{
public async ValueTask<bool> Handle(DeleteReviewCommand request, CancellationToken cancellationToken)
public async ValueTask<Unit> Handle(DeleteReviewCommand request, CancellationToken cancellationToken)
{
if (!await repository.ReviewExists(request.Id, cancellationToken))
if (!await repository.DeleteReview(request.Id, cancellationToken))
{
NotFoundException.Throw(EntityType.Review);
}

return await repository.DeleteReview(request.Id, cancellationToken);
return Unit.Value;
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
namespace CleanModelContextProtocol.Application.Reviews.Commands.UpdateReview;

using Entities;
using Mediator;

/// <summary>
/// Changes the star rating of a review. The author and the movie of a review can't change.
/// </summary>
public class UpdateReviewCommand : IRequest<bool>
public class UpdateReviewCommand : IRequest<Review>
{
/// <summary>The unique identifier of the review.</summary>
public Guid Id { get; init; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@ namespace CleanModelContextProtocol.Application.Reviews.Commands.UpdateReview;

using Common.Enums;
using Common.Exceptions;
using Entities;
using Mediator;

public class UpdateReviewHandler(IReviewsRepository reviewsRepository) : IRequestHandler<UpdateReviewCommand, bool>
public class UpdateReviewHandler(IReviewsRepository reviewsRepository) : IRequestHandler<UpdateReviewCommand, Review>
{
public async ValueTask<bool> Handle(UpdateReviewCommand request, CancellationToken cancellationToken)
public async ValueTask<Review> Handle(UpdateReviewCommand request, CancellationToken cancellationToken)
{
if (!await reviewsRepository.ReviewExists(request.Id, cancellationToken))
{
NotFoundException.Throw(EntityType.Review);
}
var review = await reviewsRepository.UpdateReview(request.Id, request.Stars, cancellationToken);

return await reviewsRepository.UpdateReview(request.Id, request.Stars, cancellationToken);
NotFoundException.ThrowIfNull(review, EntityType.Review);

return review;
}
}
4 changes: 1 addition & 3 deletions src/Application/Reviews/IReviewsRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,5 @@ public Task<Review> CreateReview(

public Task<Review?> GetReviewById(Guid id, CancellationToken cancellationToken);

public Task<bool> ReviewExists(Guid id, CancellationToken cancellationToken);

public Task<bool> UpdateReview(Guid id, int stars, CancellationToken cancellationToken);
public Task<Review?> UpdateReview(Guid id, int stars, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
namespace CleanModelContextProtocol.Application.Reviews.Queries.GetReviewById;

using System.Threading;
using System.Threading.Tasks;
using Common.Enums;
using Common.Exceptions;
using Entities;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ namespace CleanModelContextProtocol.Application.Versions.Entities;
/// <summary>
/// The version details of the running application.
/// </summary>
public class Version
public class VersionInfo
{
/// <summary>
/// The assembly file version.
Expand Down
Loading
Loading