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.
| Piece | Where |
|---|---|
IProjectsApiQueryService | Blouppy.ApiQuery.Abstractions/Projects/ |
ProjectsApiQueryService (internal sealed) | Blouppy.ApiQuery/Projects/, registered in Blouppy.ApiQuery/ServiceCollectionExtensions.cs |
IProjectsApiQueryRepository | Blouppy.Infrastructure.Database.Abstractions/Projects/ |
ProjectsApiQueryRepository | Blouppy.Infrastructure.Database/Projects/, querying AsNoTracking() |
| A projection, if the screen needs a shape the entity does not have | Blouppy.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.
| Policy | Minimum role | Use it for |
|---|---|---|
Policies.Read | Viewer | The GETs |
Policies.Contribute | Member | Creating a project, changing its status |
Policies.Manage | Admin — and from the browser only, never with a token | Deleting, settings |
The rest of the controller
GETactions injectIProjectsApiQueryService; the othersIProjectsService. 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) inBlouppy.Web/Controllers/RouteNames.cs. - Keep the short
/// <summary>on each action: it feeds the OpenAPI document, which the build writes toapi/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.