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 — GET | Writes — POST, PUT, DELETE | |
|---|---|---|
| Contract | IXxxApiQueryService in Blouppy.ApiQuery.Abstractions | IXxxService in Blouppy.<Domain>.Abstractions |
| Implementation | Blouppy.ApiQuery | Blouppy.<Domain> |
| Data access | IXxxApiQueryRepository | IXxxRepository |
| Returns | Projections and pages from Blouppy.ApiQuery.Models | Entities 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.Modelsis 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.ApiQueryinjects anIXxxApiQueryRepository. - 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>withIOffsetQuery— page numbers, for tables.CursorPage<T>withICursorQuery— an opaque cursor, for feeds and infinite lists.
Both are validated by a filter in the Web (OffsetQueryValidationFilter,
CursorQueryValidationFilter) before the controller runs.