namespace FSH.Framework.Core.Domain;/// <summary>/// Represents an entity with a strongly-typed identifier./// </summary>/// <typeparam name="TId">The type of the entity identifier.</typeparam>public interface IEntity<out TId>{ /// <summary> /// Gets the entity identifier. /// </summary> TId Id { get; }}
namespace FSH.Framework.Core.Domain;/// <summary>/// Provides a base implementation for entities with identity and domain events./// </summary>/// <typeparam name="TId">The type of the entity identifier.</typeparam>public abstract class BaseEntity<TId> : IEntity<TId>, IHasDomainEvents{ private readonly List<IDomainEvent> _domainEvents = []; /// <summary> /// Gets the entity identifier. /// </summary> public TId Id { get; protected set; } = default!; /// <summary> /// Gets the domain events raised by this entity. /// </summary> public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents; /// <summary> /// Raises and records a domain event for later dispatch. /// </summary> /// <param name="event">The domain event to add.</param> protected void AddDomainEvent(IDomainEvent @event) => _domainEvents.Add(@event); /// <summary> /// Clears all recorded domain events. /// </summary> public void ClearDomainEvents() => _domainEvents.Clear();}
Id has a protected setter so only the entity itself can set its identity.
An aggregate is a cluster of entities and value objects with a defined boundary. The aggregate root is the only entity that external code can reference.
namespace FSH.Framework.Core.Domain;/// <summary>/// Represents an aggregate root in the domain model./// </summary>/// <typeparam name="TId">The type of the aggregate identifier.</typeparam>public abstract class AggregateRoot<TId> : BaseEntity<TId>{ // Put aggregate-wide behaviors/helpers here if needed}
namespace FSH.Framework.Core.Domain;/// <summary>/// Base domain event with correlation and tenant context./// </summary>public abstract record DomainEvent( Guid EventId, DateTimeOffset OccurredOnUtc, string? CorrelationId = null, string? TenantId = null) : IDomainEvent{ /// <summary> /// Creates a new domain event using the provided factory. /// </summary> public static T Create<T>(Func<Guid, DateTimeOffset, T> factory) where T : DomainEvent { ArgumentNullException.ThrowIfNull(factory); return factory(Guid.NewGuid(), DateTimeOffset.UtcNow); }}
namespace FSH.Framework.Core.Domain;/// <summary>Defines audit metadata for an entity.</summary>public interface IAuditableEntity{ /// <summary>Gets the UTC timestamp when the entity was created.</summary> DateTimeOffset CreatedOnUtc { get; } /// <summary>Gets the identifier of the creator.</summary> string? CreatedBy { get; } /// <summary>Gets the UTC timestamp when the entity was last modified.</summary> DateTimeOffset? LastModifiedOnUtc { get; } /// <summary>Gets the identifier of the last modifier.</summary> string? LastModifiedBy { get; }}
namespace FSH.Framework.Core.Domain;/// <summary>Marks an entity as supporting soft deletion.</summary>public interface ISoftDeletable{ /// <summary>Gets a value indicating whether the entity is deleted.</summary> bool IsDeleted { get; } /// <summary>Gets the UTC timestamp when the entity was deleted.</summary> DateTimeOffset? DeletedOnUtc { get; } /// <summary>Gets the identifier of the user who deleted the entity.</summary> string? DeletedBy { get; }}
namespace FSH.Framework.Core.Domain;/// <summary>Associates an entity with a tenant.</summary>public interface IHasTenant{ /// <summary>Gets the tenant identifier.</summary> string TenantId { get; }}
Tenant isolation is enforced via global query filters.
// ❌ Bad: Anemic domain modelpublic class Order{ public Guid Id { get; set; } // Public setter! public decimal TotalAmount { get; set; } // No calculation logic public OrderStatus Status { get; set; } // No validation public List<OrderItem> Items { get; set; } = []; // Mutable list exposed}// Business logic leaked to service layerpublic class OrderService{ public async Task SubmitOrder(Guid orderId) { var order = await _repo.GetByIdAsync(orderId); // Business rules in service - BAD! if (order.Status != OrderStatus.Draft) throw new InvalidOperationException("Order already submitted."); if (!order.Items.Any()) throw new InvalidOperationException("Cannot submit empty order."); order.Status = OrderStatus.Submitted; await _repo.UpdateAsync(order); }}