3. The read side and the API

The read side

A list of projects is a read: it goes through Blouppy.ApiQuery, not through the domain service.

PieceWhere
IProjectsApiQueryServiceBlouppy.ApiQuery.Abstractions/Projects/
ProjectsApiQueryService (internal sealed)Blouppy.ApiQuery/Projects/, registered in Blouppy.ApiQuery/ServiceCollectionExtensions.cs
IProjectsApiQueryRepositoryBlouppy.Infrastructure.Database.Abstractions/Projects/
ProjectsApiQueryRepositoryBlouppy.Infrastructure.Database/Projects/, querying AsNoTracking()
A projection, if the screen needs a shape the entity does not haveBlouppy.ApiQuery.Models/Projects/

Return SearchResult<T> for a single item that may be missing, and OffsetPage<T> or CursorPage<T> for a list that can grow. The teams' read side, Blouppy.ApiQuery/Workspaces/Teams/, is the closest model.

The controller

In Blouppy.Web/Controllers/Api/Projects/ProjectsController.cs, routed under the workspace like TeamsController:

[Route("api/v{version:apiVersion}/workspaces/{workspaceId:int}/[controller]")]

A controller checks the caller's right, maps a DTO to an input record, calls one port, and maps the result — committing only on success:

/// <summary>
/// Create a project in a workspace.
/// </summary>
[HttpPost(Name = RouteNames.Api.Projects.Create)]
[ProducesResponseType(StatusCodes.Status201Created)]
public async Task<ActionResult<ProjectDto>> CreateAsync(int workspaceId, ProjectCreationDto projectCreationDto, CancellationToken cancellationToken)
{
    var typedWorkspaceId = new WorkspaceId(workspaceId);

    if (!await authorizationService.IsAuthorizedAsync(User, typedWorkspaceId, Policies.Contribute))
    {
        return Forbid();
    }

    var result = await projectsService.TryAddAsync(new ProjectCreation(projectCreationDto.Name, typedWorkspaceId), cancellationToken);

    return await result.MatchAsync(
        onSuccess: async project =>
        {
            await unitOfWork.CommitAsync(cancellationToken);

            return CreatedAtRoute(RouteNames.Api.Projects.Get, new { workspaceId, id = project.Id.Value }, ProjectMapper.MapToDto(project));
        },
        onFailed: BadRequest);
}

Who may do what

The policies are evaluated against the workspace: the caller's role in the workspace's organization decides.

PolicyMinimum roleUse it for
Policies.ReadViewerThe GETs
Policies.ContributeMemberCreating a project, changing its status
Policies.ManageAdmin — and from the browser only, never with a tokenDeleting, settings

The rest of the controller

  • GET actions inject IProjectsApiQueryService; the others IProjectsService. The controller never injects a repository.
  • Never return an entity: map it to a DTO declared in the Web.
  • Add the route names (RouteNames.Api.Projects.Create, .Get, .ChangeStatus) in Blouppy.Web/Controllers/RouteNames.cs.
  • Keep the short /// <summary> on each action: it feeds the OpenAPI document, which the build writes to api/blouppy-openapi-spec.json.

The audit log

Every write of a driving port has a row in Blouppy.Architecture.UnitTests/Architecture/AuditCoverage.cs: audited, or not audited with a reason. AuditCoverageTests fails until you add yours. Workspaces' own rows are the model — creating a workspace is not audited, as it is reversible:

["IProjectsService.TryAddAsync"] = new NotAudited(Reversible),
["IProjectsService.TryChangeStatusAsync"] = new NotAudited(WorkspaceConfiguration),

To audit a write instead, its integration event implements IAuditableIntegrationEvent and Blouppy.Audit gets a handler — see Audit log.

Try it

back/.http/ holds a runnable request per endpoint for the editor; add one for yours.