1. The model and the database
Create the projects
Create the class libraries and their test projects next to the Workspaces ones, and add
them to back/Blouppy.slnx:
| Project | References |
|---|---|
Blouppy.Projects.Models | Blouppy.SharedKernel |
Blouppy.Projects.Abstractions | Blouppy.Projects.Models |
Blouppy.Projects | Blouppy.Projects.Abstractions, Blouppy.Workspaces.PublicData, Blouppy.Infrastructure.Database.Abstractions, Blouppy.Infrastructure.Messaging.Abstractions, Blouppy.Events |
Blouppy.Projects.Models.UnitTests | Blouppy.Projects.Models |
Blouppy.Projects.UnitTests | Blouppy.Projects |
Blouppy.Workspaces.csproj is the template: it also lists the FluentValidation
packages and gives its test project access to its internal types
(InternalsVisibleTo). Package versions are central, in back/Directory.Packages.props:
write <PackageReference Include="FluentValidation" /> without a Version.
Blouppy.Infrastructure.Database.Abstractions and Blouppy.ApiQuery.Models also
reference Blouppy.Projects.Models, since they type their ports with its entity.
The strongly-typed id
Ids are records, so a ProjectId can never be passed where a WorkspaceId is expected.
In Blouppy.SharedKernel/Types/:
public sealed record ProjectId(int Value) : IntStrongType(Value);
No converter to write: the database adapter maps every strong type it finds.
The entity
In Blouppy.Projects.Models/. Plain C#, no EF attribute; the entity guards its own
invariants and records what happened:
public enum ProjectStatus
{
Planned,
Active,
Done
}
public sealed class Project(string name, WorkspaceId workspaceId) : AuditableEntityBase<ProjectId>
{
public const int MaxNameLength = 100;
public string Name { get; private set; } = Exceptions.ThrowIfNullOrWhiteSpace(name);
public ProjectStatus Status { get; private set; } = ProjectStatus.Planned;
public WorkspaceId WorkspaceId { get; private set; } = Exceptions.ThrowIfNullOrNegativeOrZero(workspaceId);
public void MarkAsCreated()
=> AddDomainEvent(new ProjectCreatedDomainEvent(Exceptions.ThrowIfNullOrNegativeOrZero(Id), WorkspaceId));
public void ChangeStatus(ProjectStatus status)
=> Status = status;
}
AuditableEntityBase<TId> adds CreatedAt, UpdatedAt, CreatedBy and UpdatedBy,
stamped by an interceptor on save. Add a private parameterless constructor for EF Core.
The EF Core configuration
In Blouppy.Infrastructure.Database/Projects/ProjectConfiguration.cs, discovered
automatically:
internal sealed class ProjectConfiguration : AuditableEntityTypeConfiguration<Project>, IModelConfiguration
{
protected override void ConfigureEntity(EntityTypeBuilder<Project> builder)
{
builder.ToTable("projects");
builder.Property(x => x.Name).HasMaxLength(Project.MaxNameLength).IsRequired();
// A real foreign key, never a bare id column: deleting the workspace deletes its projects.
builder
.HasOne<Workspace>()
.WithMany()
.HasForeignKey(x => x.WorkspaceId);
}
// Nothing else assigns an int key: without a sequence every insert would carry 0.
public void ConfigureModel(ModelBuilder modelBuilder)
=> modelBuilder.UseSequence<Project, ProjectId>(increment: 5);
}
The database adapter may name both entities; the Projects domain never names
Workspace. Then add DbSet<Project> to BlouppyDbContext.
The repository
The port, in Blouppy.Infrastructure.Database.Abstractions/Projects/IProjectsRepository.cs:
public interface IProjectsRepository
{
Task AddAsync(Project project, CancellationToken cancellationToken = default);
Task<SearchResult<Project>> TryGetAsync(ProjectId id, CancellationToken cancellationToken = default);
}
The adapter, internal sealed class ProjectsRepository(BlouppyDbContext dbContext), in
Blouppy.Infrastructure.Database/Projects/, modelled on TeamsRepository. A repository
never commits — the unit of work does. Register it as scoped in the database adapter's
ServiceCollectionExtensions.cs.
The migration
Blouppy.Web hosts several contexts, so name the one you mean:
dotnet ef migrations add Add_Project -s back/Blouppy.Web -p back/Blouppy.Infrastructure.Database \
-c BlouppyDbContext -o Migrations
Read the generated file: the sequence, the foreign key and its onDelete, and nothing
unrelated. You never apply it by hand in development — the migration runner does on the
next start. CI fails if a model change has no migration.