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
6 changes: 3 additions & 3 deletions .agents/skills/data-access-ef-core/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ One repository, `EntityFrameworkMovieReviewsRepository` in `src/Infrastructure/D
## 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 `public virtual async Task<...>` like every other method.
2. Implement it in the matching `#region` of the repository, as `public 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
- **a list**: take a `PageRequest page`, count, order (ending with the id), `Skip(page.Offset)`, `Take(page.Limit)`, `Select` the summary fields so SQL does the counting and averaging, and return `new Page<T>(items, page.Offset, page.Limit, totalCount)` (`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.
Expand All @@ -31,7 +31,7 @@ One repository, `EntityFrameworkMovieReviewsRepository` in `src/Infrastructure/D

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

Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/mapping-with-mapperly/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ metadata:

## The rule

Mapping code is generated at compile time by Mapperly (`Riok.Mapperly`), never written by hand and never done by reflection. Infrastructure's `MovieReviewsMapper` (`src/Infrastructure/Databases/MovieReviews/Mapping/MovieReviewsMapper.cs`) maps database models to Application entities and back. Presentation layers that need their own response models have their own mapper beside them.
Mapping code is generated at compile time by Mapperly (`Riok.Mapperly`), never written by hand and never done by reflection. Infrastructure's `MovieReviewsMapper` (`src/Infrastructure/Databases/MovieReviews/Mapping/MovieReviewsMapper.cs`) maps database models to Application entities. It only maps that way, because writes build the database model directly (`data-access-ef-core`). Add a mapping in the other direction only when something calls it. Presentation layers that need their own response models have their own mapper beside them.

Mapperly reports unmapped members as `RMG` warnings, and warnings fail the build, so every member is either mapped or explicitly ignored.

Expand Down
1 change: 0 additions & 1 deletion .agents/skills/mediator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ Behaviours run in the order they're registered. The first one registered is the
```csharp
// src/Application/DependencyInjection.cs
_ = services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly(), ServiceLifetime.Transient);
services.TryAddSingleton(TimeProvider.System);

_ = services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehaviour<,>));
_ = services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));
Expand Down
17 changes: 11 additions & 6 deletions .agents/skills/paging/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,19 @@ metadata:

## The rule

Every list is paged with an offset and a limit, and no endpoint returns an unbounded list. A page is `Page<T>(Items, Offset, Limit, TotalCount)` from `src/Application/Common/Paging/Page.cs`. The default limit is `Paging.DefaultLimit` (10) and the maximum is `Paging.MaxLimit` (50). To get the next page, a caller adds the limit to the offset.
Every list is paged with an offset and a limit, and no endpoint returns an unbounded list. A repository is asked for a `PageRequest(Offset, Limit)` and returns a `Page<T>(Items, Offset, Limit, TotalCount)`, both from `src/Application/Common/Paging/`. Application owns that contract; Infrastructure turns it into `Skip` and `Take`, and each presentation maps it to and from its own wire shape. The default limit is `Paging.DefaultLimit` (10) and the maximum is `Paging.MaxLimit` (50). To get the next page, a caller adds the limit to the offset.

Lists return summaries (`MovieSummary`, `AuthorSummary`) worked out in SQL, not full entities with their children. A by-id query returns the full entity.
Lists return summaries (`MovieSummary`, `AuthorSummary`) worked out in SQL, not full entities with their children. A by-id query returns the full entity with all its children. That is a deliberate limit of the sample data model: when a parent can have many children, return its summary and page the children through their own list query instead.

When one response holds a page of parents and each one needs its children, ask for the children of the whole page in one trip with `GetReviewsForMoviesQuery` or `GetReviewsForAuthorsQuery`. They take at most `Paging.MaxLimit` ids, which is one page, and return an entry for every id. GraphQL uses them in its DataLoaders and MCP uses them for `includeReviews`. The REST templates don't call them, because their lists return summaries and a by-id query loads its children with the parent, but they stay in Application so the shared layers match in every template.

## Steps

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)`; `Page<T>` exposes the items as an `IReadOnlyList<T>`. Count before you skip and take.
3. Give the repository method a `PageRequest page` parameter after any filters, and have it return `Page<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)`.
5. The handler passes the page through: `return await repository.GetMovies(request.Query, new PageRequest(request.Offset, request.Limit), cancellationToken);`

## Example

Expand All @@ -30,8 +32,8 @@ var totalCount = await movies.CountAsync(cancellationToken);
var result = await movies
.OrderBy(m => m.Title)
.ThenBy(m => m.Id)
.Skip(offset)
.Take(limit)
.Skip(page.Offset)
.Take(page.Limit)
.Select(m => new
{
m.Id,
Expand All @@ -40,12 +42,15 @@ var result = await movies
AverageStars = m.Reviews.Average(r => (double?)r.Stars)
})
.ToListAsync(cancellationToken);

return new([.. result.Select(m => new ApplicationMovieSummary(m.Id, m.Title, m.ReviewCount, RoundStars(m.AverageStars)))], page.Offset, page.Limit, totalCount);
```

## Don't

- Add an endpoint or query that returns every row.
- Use cursors or page numbers. Offset and limit everywhere; a presentation layer that needs cursors (GraphQL connections) wraps the offset.
- Return a tuple or a bare list and build the page in the handler.
- Order only by a column that can repeat, such as a title.
- `Include` child collections on a list just to count or average them. Project the numbers in SQL.

Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/time-provider/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ metadata:

## The rule

Nothing reads the clock directly. Code that needs the time takes `TimeProvider` through its constructor and calls `GetUtcNow()`. Production gets `TimeProvider.System`, registered with `TryAddSingleton` in both `AddApplication` and `AddInfrastructure`, so either layer works on its own and the clock is only registered once. Tests pass a `FakeTimeProvider` (`Microsoft.Extensions.TimeProvider.Testing`) set to a fixed moment, so every date is known in advance.
Nothing reads the clock directly. Code that needs the time takes `TimeProvider` through its constructor and calls `GetUtcNow()`. Production gets `TimeProvider.System`, registered with `TryAddSingleton` in `AddInfrastructure` only. The system clock is an outside dependency, and Infrastructure owns those; Application only uses the abstract `TimeProvider`, which is part of .NET and needs no package. Every host calls `AddInfrastructure`, and `TryAdd` lets a host or test register its own clock first. Tests pass a `FakeTimeProvider` (`Microsoft.Extensions.TimeProvider.Testing`) set to a fixed moment, so every date is known in advance.

Times are stored and compared in UTC. Two things read the clock today. The repository stamps `DateCreated` and `DateModified` on writes, and `LoggingBehaviour` times each request with `GetTimestamp()` and `GetElapsedTime()`.

Expand Down
6 changes: 3 additions & 3 deletions src/Application/Authors/IAuthorsRepository.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
namespace CleanLambdaFunction.Application.Authors;

using Common.Paging;
using Entities;

public interface IAuthorsRepository
{
public Task<(List<AuthorSummary> Items, int TotalCount)> GetAuthors(
public Task<Page<AuthorSummary>> GetAuthors(
string? query,
int offset,
int limit,
PageRequest page,
CancellationToken cancellationToken);

public Task<Author?> GetAuthorById(Guid id, CancellationToken cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ public class GetAuthorsHandler(IAuthorsRepository repository) : IRequestHandler<
{
public async ValueTask<Page<AuthorSummary>> Handle(GetAuthorsQuery request, CancellationToken cancellationToken)
{
var (items, totalCount) = await repository.GetAuthors(request.Query, request.Offset, request.Limit, cancellationToken);

return new Page<AuthorSummary>(items, request.Offset, request.Limit, totalCount);
return await repository.GetAuthors(request.Query, new PageRequest(request.Offset, request.Limit), cancellationToken);
}
}
8 changes: 8 additions & 0 deletions src/Application/Common/Paging/PageRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace CleanLambdaFunction.Application.Common.Paging;

/// <summary>
/// The slice of a list a use case asks a repository for.
/// </summary>
/// <param name="Offset">How many items to skip. 0 is the first page.</param>
/// <param name="Limit">The most items to return.</param>
public record PageRequest(int Offset, int Limit);
2 changes: 0 additions & 2 deletions src/Application/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,13 @@ namespace CleanLambdaFunction.Application;
using FluentValidation;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

[ExcludeFromCodeCoverage]
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
_ = services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly(), ServiceLifetime.Transient);
services.TryAddSingleton(TimeProvider.System);

_ = services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehaviour<,>));
_ = services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));
Expand Down
6 changes: 3 additions & 3 deletions src/Application/Movies/IMoviesRepository.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
namespace CleanLambdaFunction.Application.Movies;

using Common.Paging;
using Entities;

public interface IMoviesRepository
{
public Task<(List<MovieSummary> Items, int TotalCount)> GetMovies(
public Task<Page<MovieSummary>> GetMovies(
string? query,
int offset,
int limit,
PageRequest page,
CancellationToken cancellationToken);

public Task<Movie?> GetMovieById(Guid id, CancellationToken cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ public class GetMoviesHandler(IMoviesRepository repository) : IRequestHandler<Ge
{
public async ValueTask<Page<MovieSummary>> Handle(GetMoviesQuery request, CancellationToken cancellationToken)
{
var (items, totalCount) = await repository.GetMovies(request.Query, request.Offset, request.Limit, cancellationToken);

return new Page<MovieSummary>(items, request.Offset, request.Limit, totalCount);
return await repository.GetMovies(request.Query, new PageRequest(request.Offset, request.Limit), cancellationToken);
}
}
6 changes: 3 additions & 3 deletions src/Application/Reviews/IReviewsRepository.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
namespace CleanLambdaFunction.Application.Reviews;

using Common.Paging;
using Entities;

public interface IReviewsRepository
Expand All @@ -12,11 +13,10 @@ public Task<Review> CreateReview(

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

public Task<(List<Review> Items, int TotalCount)> GetReviews(
public Task<Page<Review>> GetReviews(
Guid? movieId,
Guid? authorId,
int offset,
int limit,
PageRequest page,
CancellationToken cancellationToken);

public Task<List<Review>> GetReviewsForMovies(IReadOnlyCollection<Guid> movieIds, CancellationToken cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ public class GetReviewsHandler(IReviewsRepository repository) : IRequestHandler<
{
public async ValueTask<Page<Review>> Handle(GetReviewsQuery request, CancellationToken cancellationToken)
{
var (items, totalCount) = await repository.GetReviews(request.MovieId, request.AuthorId, request.Offset, request.Limit, cancellationToken);

return new Page<Review>(items, request.Offset, request.Limit, totalCount);
return await repository.GetReviews(request.MovieId, request.AuthorId, new PageRequest(request.Offset, request.Limit), cancellationToken);
}
}
16 changes: 16 additions & 0 deletions src/Application/Versions/Entities/VersionInfo.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
namespace CleanLambdaFunction.Application.Versions.Entities;

using System.Reflection;

/// <summary>
/// The version details of the running application.
/// </summary>
Expand All @@ -16,4 +18,18 @@ public class VersionInfo
/// </summary>
/// <example>1.1.0+e30eba8</example>
public required string InformationalVersion { get; init; }

/// <summary>
/// Reads the version attributes of the assembly the host hands over, usually its own.
/// </summary>
/// <param name="assembly">The assembly to read, or null for empty versions.</param>
/// <returns>The assembly's file and informational versions.</returns>
public static VersionInfo FromAssembly(Assembly? assembly)
{
return new VersionInfo
{
FileVersion = $"{assembly?.GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version}",
InformationalVersion = $"{assembly?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion}"
};
}
}
Original file line number Diff line number Diff line change
@@ -1,35 +1,12 @@
namespace CleanLambdaFunction.Application.Versions.Queries.GetVersion;

using System.Reflection;
using Entities;
using Mediator;

public class GetVersionHandler : IRequestHandler<GetVersionQuery, VersionInfo>
public class GetVersionHandler(VersionInfo version) : IRequestHandler<GetVersionQuery, VersionInfo>
{
private readonly Assembly? assembly;

/// <summary>Reads the version of the app that's running.</summary>
public GetVersionHandler()
: this(Assembly.GetEntryAssembly())
{
}

/// <summary>Reads the version of the given assembly, so tests can choose one with known values.</summary>
internal GetVersionHandler(Assembly? assembly)
{
this.assembly = assembly;
}

public ValueTask<VersionInfo> Handle(GetVersionQuery request, CancellationToken cancellationToken)
{
var version = new VersionInfo
{
FileVersion =
$"{this.assembly?.GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version}",
InformationalVersion =
$"{this.assembly?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion}"
};

return ValueTask.FromResult(version);
}
}
Loading
Loading