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> in Blouppy.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.