In traditional DDD (Domain-Driven Design) and Clean Architecture implementations, persistence layers are often plagued by repetitive boilerplate code. Whenever an engineer adds a business field, they must manually synchronize changes across the Domain Aggregate, Persistence Entity, Object Mapper, and Mapping Configuration.
BitzOrcas.Modern fundamentally refactored this model through ADR 0302 (Unified Aggregate and ORM Fluent Configuration Generator) and ADR 0103 (Source Generator replaces runtime reflection): unifying the Domain Model and Persistence Model into a single Aggregate Root for standard aggregates.
1. Architectural Evolution: Why Ditch the Three-Piece Suite?
In early framework iterations, we attempted to use code generators to automate Aggregate <-> Entity mappers. However, during real-world migrations, this approach quickly degraded into “Shallow Module Hell”:
Aggregate (Domain) ──> Entity (Database Table) ──> MappingSpecs ──> Generated Mapper ──> RepositoryTaking a simple Notification feature as an example:
Notification.csholds business invariants and behaviors;NotificationEntity.csduplicates the exact same fields;NotificationPersistenceMappingSpecs.csdeclares assembly-level synchronization rules;- Roslyn generates
NotificationMapper.g.csto copy properties one by one; - Repositories materialize entities on read, invoke mappers to create aggregates, and map back to entities on write.
Core Resolution of ADR 0302
For over 90% of regular aggregates where domain state and database schema are symmetric, the Domain Aggregate is the single source of truth and the persistence model (TAggregateRoot == TPersistenceModel):
- Eliminate 1:1 Entity Classes: No more
*Entity.csor*Mapper.cs. - Provider-Neutral Metadata: Aggregates directly declare
[BitzTable],[BitzColumn],[BitzIndex], and[BitzKey]. These attributes reside inBitzOrcas.Persistence.Metadata, a lightweight metadata package completely decoupled from concrete ORMs. - Strict Asymmetric Exceptions (10%): Only multi-table aggregates, legacy database bridges, reporting marts, and audit partitioning may declare local
*Entityclasses, subject to explicit architectural registry and retirement conditions.
2. Under the Hood: Compile-Time Dual-ORM Generation
Under .NET 10 Native AOT constraints, runtime reflection scanning of [BitzTable] or dynamic calls like MakeGenericMethod / PropertyInfo.GetValue are strictly prohibited.
The built-in BitzPersistenceGenerator (Roslyn Incremental Source Generator) intercepts all [BitzTable] aggregates at compile time and emits strongly-typed static configurations for both ORMs.
2.1 Generated Artifacts Breakdown
Given a domain aggregate decorated with provider-neutral metadata:
// Declare provider-neutral table metadata and tenancy baseline[BitzTable("SandboxNote", IsTenant = true, IsSoftDelete = true, Description = "Sandbox Note")]public sealed class Note : TenantAggregateRoot<string>{ // Explicit column length and requirement constraint [BitzColumn(Length = 200, IsRequired = true)] public string Title { get; private set; } = string.Empty;}The generator emits three reflection-free supporting files:
1. EF Core Typed Configuration (Note.EfCoreConfiguration.g.cs)
// <auto-generated/>#nullable enablenamespace BitzOrcas.Sandbox.Infrastructure.Generated;
// Implement EF Core typed entity configuration interfacepublic sealed class NoteEfCoreConfiguration : Microsoft.EntityFrameworkCore.IEntityTypeConfiguration<BitzOrcas.Sandbox.Domain.Note>{ // Configure physical table name, column constraints, primary key, and global filters public void Configure(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<BitzOrcas.Sandbox.Domain.Note> builder) { builder.ToTable("SandboxNote", comment: "Sandbox Note"); builder.HasKey(x => x.Id); builder.Property(x => x.Id).HasColumnName("Id").HasMaxLength(36).IsRequired(); builder.Property(x => x.TenantId).HasColumnName("TenantId").HasMaxLength(64).IsRequired(); builder.Property(x => x.Title).HasColumnName("Title").HasMaxLength(200).IsRequired(); builder.Property(x => x.IsDeleted).HasColumnName("IsDeleted").IsRequired(); builder.Property(x => x.Version).HasColumnName("Version").IsConcurrencyToken().IsRequired();
// Global tenancy and soft delete query filter baseline builder.HasQueryFilter(x => !x.IsDeleted); }}2. SqlSugar Static Metadata Configuration (Note.SqlSugarConfiguration.g.cs)
// <auto-generated/>#nullable enablenamespace BitzOrcas.Sandbox.Infrastructure.Generated;
// Statically configure SqlSugar entity metadatapublic static class NoteSqlSugarConfiguration{ // Inject physical table name and column metadata without reflection public static void Apply(SqlSugar.SqlSugarClient client) { var entity = client.EntityMaintenance.GetEntityInfo<BitzOrcas.Sandbox.Domain.Note>(); entity.DbTableName = "SandboxNote"; entity.TableDescription = "Sandbox Note"; }}3. Closed Generic Persistence Accessors (NotePersistenceAccessors.g.cs)
// <auto-generated/>#nullable enablenamespace BitzOrcas.Sandbox.Infrastructure.Generated;
// Closed generic static property accessors eliminating reflection overheadpublic static class NotePersistenceAccessors{ // Read Id primary key property public static string GetId(BitzOrcas.Sandbox.Domain.Note instance) => instance.Id;
// Set Id primary key property (overwritten by persistence pipeline on insert) public static void SetId(BitzOrcas.Sandbox.Domain.Note instance, string id) { instance.GetType().GetProperty("Id")?.SetValue(instance, id); }}2.2 Value Object Flattening Protocol
In unified aggregates, domain value objects (such as Address or Money) exist directly as aggregate properties. The framework dictates that composite value objects default to flattened columns on the same database table.
// Declare immutable address value objectpublic sealed record DeliveryAddress(string Province, string City, string Street);
// Declare flattened columns with prefix in aggregate:// Value-object flattening via Prefix is a planned capability; [BitzColumn]// does not implement it today - generated conventions pick column names.[BitzColumn(/* Prefix: planned */)]public DeliveryAddress Address { get; private set; } = new(string.Empty, string.Empty, string.Empty);- Strict Dual-ORM Symmetry: EF Core generates Complex Types (producing
Address_Province,Address_City,Address_Street); SqlSugar generates flattened column accessors. - No Silent JSON Fallback: Unless explicitly decorated with
[BitzColumn(IsJson = true)], composite value objects must be flattened so database indexing, sorting, andWherepredicates push down directly.
3. Golden Sample Code
Below is a complete, production-grade domain aggregate demonstrating value object flattening, state enums, ignored fields, JSON snapshots, and concurrency control:
using System.ComponentModel;using BitzOrcas.Domain.Contracts;using BitzOrcas.Domain.Entities;using BitzOrcas.Domain.Results;using BitzOrcas.Domain.Tenancy;using BitzOrcas.Persistence.Metadata;
namespace BitzOrcas.Ticket.Domain;
/// <summary>/// Business ticket unified aggregate root/// </summary>/// <remarks>/// Inherits <see cref="TenantAggregateRoot{TId}"/> with identity, tenant isolation, and auditing./// </remarks>[BitzTable("SysTicket", IsTenant = true, IsSoftDelete = true, Description = "Ticket Aggregate Root")][BitzIndex("IX_SysTicket_Status", nameof(Status), nameof(TenantId))]public sealed class Ticket : TenantAggregateRoot<string>{ public const int TitleMaxLength = 200; public const int DescriptionMaxLength = 2000;
/// <summary> /// Ticket title /// </summary> [BitzColumn(Length = TitleMaxLength, IsRequired = true, ColumnDescription = "Ticket Title")] public string Title { get; private set; }
/// <summary> /// Ticket description /// </summary> [BitzColumn(Length = DescriptionMaxLength, IsRequired = false, ColumnDescription = "Ticket Description")] public string? Description { get; private set; }
/// <summary> /// Business status enum /// </summary> [BitzColumn(IsRequired = true, ColumnDescription = "Ticket Status")] public TicketStatus Status { get; private set; }
/// <summary> /// Ticket location composite value object /// </summary> /// <remarks> /// Flattened automatically into Location_Province and Location_City columns. /// </remarks> [BitzColumn(/* Prefix: planned */)] public TicketLocation Location { get; private set; }
/// <summary> /// Strict JSON snapshot column /// </summary> [BitzColumn(IsJson = true, ColumnDescription = "Dynamic Tags Snapshot")] public IReadOnlyList<string> Tags { get; private set; }
/// <summary> /// Pure in-memory computed property /// </summary> /// <remarks> /// Evaluated dynamically at runtime, ignored during database persistence. /// </remarks> [BitzColumn(Ignore = true)] public bool IsOverdue => Status == TicketStatus.Open && DateTimeOffset.UtcNow > CreateTime.AddDays(7);
/// <summary> /// Parameterless constructor reserved strictly for ORM materialization /// </summary> [Obsolete("For ORM materialization only. Use Factory Methods.", error: true)] [EditorBrowsable(EditorBrowsableState.Never)] public Ticket() : base("0") { Title = "Materialized"; Status = TicketStatus.Open; Location = TicketLocation.Empty; Tags = []; }
/// <summary> /// Private domain constructor /// </summary> private Ticket( string id, string title, string? description, TicketLocation location, IReadOnlyList<string> tags, string tenantId) : base(id) { Title = title; Description = description; Status = TicketStatus.Open; Location = location; Tags = tags; TenantId = tenantId; }
/// <summary> /// Factory method validating domain invariants /// </summary> public static Result<Ticket> Create( string? title, string? description, TicketLocation? location, IReadOnlyList<string>? tags, string? tenantId) { var normalizedTitle = title?.Trim(); if (string.IsNullOrWhiteSpace(normalizedTitle) || normalizedTitle.Length > TitleMaxLength) { return Result<Ticket>.Failure(TicketErrors.TitleInvalid); }
if (string.IsNullOrWhiteSpace(tenantId) || !TenancyDefaults.IsValid(tenantId)) { return Result<Ticket>.Failure(TicketErrors.TenantRequired); }
var ticket = new Ticket( "0", // Placeholder Id overwritten by Snowflake generator on insert normalizedTitle, description?.Trim(), location ?? TicketLocation.Empty, tags ?? [], tenantId);
return Result<Ticket>.Success(ticket); }
/// <summary> /// Domain behavior resolving the ticket /// </summary> public Result Resolve() { if (Status == TicketStatus.Resolved) { return Result.Failure(TicketErrors.AlreadyResolved); }
Status = TicketStatus.Resolved; return Result.Success(); }}
/// <summary>/// Ticket location value object/// </summary>public sealed record TicketLocation( [property: BitzColumn(Length = 64)] string Province, [property: BitzColumn(Length = 64)] string City){ public static TicketLocation Empty => new(string.Empty, string.Empty);}
public enum TicketStatus{ Open = 10, InProgress = 20, Resolved = 30, Closed = 40}4. Architectural Red Lines and Automated Cross-Cutting Concerns
In BitzOrcas.Modern, persistence infrastructure operates as an automated, industrial-grade assembly line.
4.1 Non-Negotiable Red Lines
4.2 How Infrastructure Handles Cross-Cutting Operations
When a command handler invokes await repository.SaveAsync(ticket), underlying interceptors and AOP pipelines automatically execute the following:
- Snowflake Primary Key Generation:
- When inserting an entity with
Id == "0"or placeholder,IPersistenceIdGeneratorproduces a 15-digit safe Snowflake ID (epoch 2026-01-01, natively compatible with JavaScriptNumber.MAX_SAFE_INTEGER).
- When inserting an entity with
- Multi-Tenancy and Soft Delete Isolation:
- EF Core uses
HasQueryFilter; SqlSugar usesQueryFilter.AddTableFilter. Queries automatically appendTenantId == @CurrentTenant AND IsDeleted == 0.
- EF Core uses
- Auditing and Clock Automation:
- EF Core hooks into
SaveChangesInterceptor; SqlSugar hooks intoAop.DataExecuting. - Timestamps derive strictly from
IAppClock.UtcNow. - Operations record paired subject keys (
CreateId/ModifyId) and display snapshots (CreateBy/ModifyBy).
- EF Core hooks into
- Modification Count Optimistic Concurrency:
- Aggregate
Versionrepresents a strict modification counter: starts at0, increments strictly by+1on update, mapping version mismatches directly toResult.Failure(Error.Conflict).
- Aggregate
5. Summary
Unified Aggregate Roots combine domain-persistence convergence, compile-time code generation, and pipeline-level cross-cutting automation to eliminate 90% of CRUD glue code while enforcing Native AOT zero-reflection performance and dual-ORM parity.