2. The write side
The driving port
In Blouppy.Projects.Abstractions/: what the Web may ask, and the inputs it sends.
Writes return a ValidationResult<T> (or a DeletionResult for a delete):
public sealed record ProjectCreation(string Name, WorkspaceId WorkspaceId);
public sealed record ProjectStatusChange(ProjectId Id, ProjectStatus Status);
public interface IProjectsService
{
Task<ValidationResult<Project>> TryAddAsync(ProjectCreation projectCreation, CancellationToken cancellationToken = default);
Task<ValidationResult<Project>> TryChangeStatusAsync(ProjectStatusChange statusChange, CancellationToken cancellationToken = default);
}
The validators
In Blouppy.Projects/. FluentValidation, internal sealed, discovered by the domain's
registration.
A project needs a workspace that exists — and the workspace belongs to another domain.
The validator asks Workspaces' public contract, IWorkspacesPublicDataService, which
answers with a fact rather than an entity:
internal sealed class ProjectCreationValidator : AbstractValidator<ProjectCreation>
{
public ProjectCreationValidator(IWorkspacesPublicDataService workspaces)
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(Project.MaxNameLength);
RuleFor(x => x.WorkspaceId)
.MustAsync(async (workspaceId, cancellationToken) =>
await workspaces.TryGetOrganizationIdAsync(workspaceId, cancellationToken) is not null)
.WithMessage("The workspace does not exist.");
}
}
TryGetOrganizationIdAsync is the one method Workspaces' .PublicData offers today. If
your rule needs another fact — the workspace's name, its owner — add a method there,
implement it in WorkspacesPublicDataService, and keep what it returns to scalars and
ids. Never inject IWorkspacesRepository: DomainBoundaryTests refuses it.
ProjectStatusChangeValidator checks RuleFor(x => x.Status).IsInEnum().
The service
In Blouppy.Projects/ProjectsService.cs: validate, build or load the entity, hand it to
the repository, raise the event. It does not commit:
internal sealed class ProjectsService(
IProjectsRepository projectsRepository,
IValidator<ProjectCreation> projectCreationValidator,
IValidator<ProjectStatusChange> projectStatusChangeValidator) : IProjectsService
{
public async Task<ValidationResult<Project>> TryAddAsync(ProjectCreation projectCreation, CancellationToken cancellationToken = default)
{
var result = await projectCreationValidator.ValidateAsync(projectCreation, cancellationToken);
if (!result.IsValid)
{
return ValidationResult.Failure<Project>(result.ToDictionary());
}
var project = new Project(projectCreation.Name, projectCreation.WorkspaceId);
await projectsRepository.AddAsync(project, cancellationToken);
project.MarkAsCreated();
return ValidationResult.Success(project);
}
public async Task<ValidationResult<Project>> TryChangeStatusAsync(ProjectStatusChange statusChange, CancellationToken cancellationToken = default)
{
var result = await projectStatusChangeValidator.ValidateAsync(statusChange, cancellationToken);
if (!result.IsValid)
{
return ValidationResult.Failure<Project>(result.ToDictionary());
}
// A missing project becomes a 404, through the Web's exception handler.
var project = await projectsRepository.TryGetAsync(statusChange.Id, cancellationToken)
.OrThrowResourceNotFoundAsync(statusChange.Id);
project.ChangeStatus(statusChange.Status);
return ValidationResult.Success(project);
}
}
Reacting to events
- Inside the transaction — something that must commit with the project: an
IDomainEventHandler<ProjectCreatedDomainEvent>inBlouppy.Projects/DomainEventHandlers/. - For another domain or module — publish an integration event from such a handler,
declared in
Blouppy.Infrastructure.Messaging.Abstractions/Projects/. The audit log, the webhooks and the notifications all listen this way. See Events and messaging.
And the other way round: deleting a workspace deletes its projects through the foreign key, in the same transaction — no event needed.
Registration
The domain registers itself, in Blouppy.Projects/ServiceCollectionExtensions.cs —
the scans bind to this assembly, so the call cannot be made from elsewhere:
public static class ServiceCollectionExtensions
{
extension(IServiceCollection services)
{
public IServiceCollection AddProjectsDomain()
{
var assembly = typeof(ServiceCollectionExtensions).Assembly;
services.AddValidatorsFromAssembly(assembly, includeInternalTypes: true);
services.AddBlouppyEvents(options => options.RegisterServicesFromAssemblies(assembly));
services.AddIntegrationEventHandlers(assembly);
services.AddScoped<IProjectsService, ProjectsService>();
return services;
}
}
}
Every ServiceCollectionExtensions.cs in the Starter uses a C# 14 extension block
(extension(IServiceCollection services) { … }) rather than this parameters.
Then call it from AddDomainServices() in Blouppy.Web/DomainServiceRegistration.cs,
next to AddWorkspacesDomain(). Forgetting either call compiles — and silently does
nothing, which is why step 5 adds a registration test.