Learn how to create domain entities with strongly-typed IDs following Domain-Driven Design principles.
Overview​
Domain entities represent core business concepts in your application. In AppBlueprint, all entities follow these patterns:
- Strongly-typed IDs using ULIDs
- Factory methods for creation
- Business logic encapsulated in entity methods
- Proper encapsulation with private setters
Step 1: Define the Domain Entity​
Create your aggregate root in the Domain layer.
Location: Shared-Modules/AppBlueprint.Domain/Entities/Project.cs
using AppBlueprint.SharedKernel;
namespace AppBlueprint.Domain.Entities;
/// <summary>
/// Represents a project within a team
/// </summary>
public sealed class Project
{
// Strongly-typed ID
public ProjectId Id { get; private set; } = null!;
// Required properties
public required string Name { get; set; }
public required string Description { get; set; }
public required TeamId TeamId { get; set; }
// Enum for type-safe status
public ProjectStatus Status { get; private set; }
// Timestamps
public DateTime CreatedAt { get; private set; }
public DateTime? UpdatedAt { get; private set; }
// Navigation properties (for EF Core)
public Team Team { get; set; } = null!;
// Factory method - preferred way to create entities
public static Project Create(string name, string description, TeamId teamId)
{
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(description);
ArgumentNullException.ThrowIfNull(teamId);
return new Project
{
Id = ProjectId.NewId(),
Name = name,
Description = description,
TeamId = teamId,
Status = ProjectStatus.Active,
CreatedAt = DateTime.UtcNow
};
}
// Business methods - encapsulate domain logic
public void UpdateDetails(string name, string description)
{
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(description);
Name = name;
Description = description;
UpdatedAt = DateTime.UtcNow;
}
public void Archive()
{
Status = ProjectStatus.Archived;
UpdatedAt = DateTime.UtcNow;
}
public void Activate()
{
Status = ProjectStatus.Active;
UpdatedAt = DateTime.UtcNow;
}
}
public enum ProjectStatus
{
Active = 1,
Archived = 2,
Completed = 3
}
Step 2: Create Strongly-Typed ID​
Every entity must have its own strongly-typed ID type.
Location: Shared-Modules/AppBlueprint.SharedKernel/StronglyTypedIds/ProjectId.cs
using System.ComponentModel;
using System.Globalization;
using System.Text.Json.Serialization;
namespace AppBlueprint.SharedKernel;
/// <summary>
/// Strongly-typed identifier for Project entity
/// </summary>
[TypeConverter(typeof(ProjectIdTypeConverter))]
[JsonConverter(typeof(ProjectIdJsonConverter))]
public readonly record struct ProjectId
{
public Ulid Value { get; }
public ProjectId(Ulid value) => Value = value;
public static ProjectId NewId() => new(Ulid.NewUlid());
public static ProjectId Parse(string value) => new(Ulid.Parse(value));
public static bool TryParse(string? value, out ProjectId result)
{
if (Ulid.TryParse(value, out var ulid))
{
result = new ProjectId(ulid);
return true;
}
result = default;
return false;
}
public override string ToString() => Value.ToString();
// Type converter for model binding in controllers
private class ProjectIdTypeConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
=> sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
=> value is string str ? Parse(str) : base.ConvertFrom(context, culture, value);
public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
=> destinationType == typeof(string) && value is ProjectId id ? id.ToString() : base.ConvertTo(context, culture, value, destinationType);
}
// JSON converter for serialization
private class ProjectIdJsonConverter : JsonConverter<ProjectId>
{
public override ProjectId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> Parse(reader.GetString()!);
public override void Write(Utf8JsonWriter writer, ProjectId value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToString());
}
}
Step 3: Configure Entity in DbContext​
Configure the entity mapping and relationships in Entity Framework Core.
Location: Shared-Modules/AppBlueprint.Infrastructure/DatabaseContexts/ApplicationDbContext.cs
// Add DbSet property
public DbSet<Project> Projects => Set<Project>();
// Configure entity in OnModelCreating
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Project>(entity =>
{
// Primary key
entity.HasKey(e => e.Id);
// Strongly-typed ID conversion
entity.Property(e => e.Id)
.HasConversion(
id => id.Value.ToString(),
value => new ProjectId(Ulid.Parse(value)))
.IsRequired();
// String properties with constraints
entity.Property(e => e.Name)
.HasMaxLength(200)
.IsRequired();
entity.Property(e => e.Description)
.HasMaxLength(2000);
// Enum stored as integer
entity.Property(e => e.Status)
.HasConversion<int>()
.IsRequired();
// Timestamp properties
entity.Property(e => e.CreatedAt)
.IsRequired();
entity.Property(e => e.UpdatedAt);
// Relationships
entity.HasOne(e => e.Team)
.WithMany()
.HasForeignKey(e => e.TeamId)
.OnDelete(DeleteBehavior.Cascade);
// Indexes for performance
entity.HasIndex(e => e.TeamId);
entity.HasIndex(e => new { e.TeamId, e.Status });
});
}
Step 4: Create and Apply Migration​
cd Shared-Modules/AppBlueprint.Infrastructure
# Create migration
dotnet ef migrations add AddProjectEntity --context ApplicationDbContext
# Review the generated migration file
# Then apply it to the database
dotnet ef database update --context ApplicationDbContext
Best Practices​
✅ DO​
- Use factory methods (
Create()) for entity creation - Keep business logic in domain entities
- Use strongly-typed IDs for all entities
- Use
requiredkeyword for mandatory properties - Use private setters to enforce encapsulation
- Validate inputs in business methods
- Use UTC for all timestamps
❌ DON'T​
- Don't use
stringorGuiddirectly for IDs - Don't put data access logic in entities
- Don't expose public setters for business rules
- Don't create entities with invalid state
- Don't use nullable properties when not needed
Common Patterns​
Value Objects​
For complex types that don't have identity:
public readonly record struct Money(decimal Amount, string Currency);
Aggregate Relationships​
// One-to-Many
entity.HasMany(e => e.Tasks)
.WithOne(t => t.Project)
.HasForeignKey(t => t.ProjectId);
// Many-to-Many
entity.HasMany(e => e.Tags)
.WithMany(t => t.Projects)
.UsingEntity(j => j.ToTable("ProjectTags"));
Next Steps​
Examples in Codebase​
See existing implementations:
TodoEntity- Simple entity with basic CRUDTeamEntity- Entity with relationshipsOrganizationEntity- Complex entity with multiple relationships