Reads and writes

Two verticals sit above the same domain, and nothing crosses diagonally. The Web picks one by HTTP verb, and never injects a repository.

Reads — GETWrites — POST, PUT, DELETE
ContractIXxxApiQueryService in Blouppy.ApiQuery.AbstractionsIXxxService in Blouppy.<Domain>.Abstractions
ImplementationBlouppy.ApiQueryBlouppy.<Domain>
Data accessIXxxApiQueryRepositoryIXxxRepository
ReturnsProjections and pages from Blouppy.ApiQuery.ModelsEntities wrapped in ValidationResult<T>

Why reads get their own side

Much of what a screen shows has no identity, no behaviour and no invariant: a member listed with the name and avatar joined from another table, an organization's usage against its plan, a page of results. None of that is a domain entity, and forcing it into one would bloat the model with shapes that exist only for display.

So a read is a projection: a query that selects exactly the columns a screen needs, straight to SQL, with no entity materialised and no domain service involved.

MembersController shows both verticals side by side — it injects IOrganizationMembersApiQueryService for its GETs and IOrganizationMembersService for its writes:

// GET: a projection page, straight from the read side.
var detailedMembers = await organizationMembersApiQueryService.GetAsync(filter, cancellationToken);

Why writes return a result, not an exception

A write is validated at the domain boundary with FluentValidation, and the outcome is a value: ValidationResult<T> carries either the entity or the errors, DeletionResult says whether the row was deleted, missing or refused. The controller maps each outcome to a status code — and commits the unit of work only on success:

// DELETE: the domain decides, the controller maps and commits.
var result = await organizationMembersService.TryDeleteAsync(
    new OrganizationMemberDeletion(typedOrganizationId, new UserId(id)), cancellationToken);

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

        return NoContent();
    },
    onNotFound: () => NotFound($"Member ({id}) not found in the organization."),
    onRefused: BadRequest);

Nothing is thrown for a rule the user broke, and nothing reaches the database until the controller says so: one request, one commit.

Validators belong to the write side and read through the write repositories — the workspace count the plan limit is checked against is read by the repository the validator already has, never by a read port.

The rules, as tests

ReadSideBoundaryTests holds four of them:

  • A read projection belongs in Blouppy.ApiQuery.Models — never in a domain's .Models, never duplicated in an .Abstractions.
  • Blouppy.ApiQuery.Models is a leaf: it depends on the domain models only. That is what lets the database ports type their read methods without referencing the read layer.
  • Only Blouppy.ApiQuery injects an IXxxApiQueryRepository.
  • The read side never references a .PublicData: that door is for the write side.

Paging

Blouppy.ApiQuery.Models/Paging/ holds the two shapes, used by every list endpoint:

  • OffsetPage<T> with IOffsetQuery — page numbers, for tables.
  • CursorPage<T> with ICursorQuery — an opaque cursor, for feeds and infinite lists.

Both are validated by a filter in the Web (OffsetQueryValidationFilter, CursorQueryValidationFilter) before the controller runs.