Skip to content
bitzorcas
中EN

Reference

Unified Aggregate and Persistence Kernel

A comprehensive architectural deep dive into why BitzOrcas.Modern eliminated the traditional Entity + Mapper + Aggregate boilerplate, and how the compile-time ORM Fluent Configuration Generator achieves zero-reflection dual-ORM parity.

Last updated

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”:

Legacy Shallow Module Pipeline (Deprecated)
Aggregate (Domain) ──> Entity (Database Table) ──> MappingSpecs ──> Generated Mapper ──> Repository

Taking a simple Notification feature as an example:

  1. Notification.cs holds business invariants and behaviors;
  2. NotificationEntity.cs duplicates the exact same fields;
  3. NotificationPersistenceMappingSpecs.cs declares assembly-level synchronization rules;
  4. Roslyn generates NotificationMapper.g.cs to copy properties one by one;
  5. 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):

Roslyn Compile-TimeInterceptionGenerated ArtifactGenerated ArtifactGenerated Artifact

Domain Aggregate
(with [BitzTable] / [BitzColumn])

ORM Fluent Config Generator

EfCoreConfiguration.g.cs

SqlSugarConfiguration.g.cs

PersistenceAccessors.g.cs

EF Core DbContext

SqlSugar Client

AOT Zero-Reflection Accessors

  • Eliminate 1:1 Entity Classes: No more *Entity.cs or *Mapper.cs.
  • Provider-Neutral Metadata: Aggregates directly declare [BitzTable], [BitzColumn], [BitzIndex], and [BitzKey]. These attributes reside in BitzOrcas.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 *Entity classes, 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:

Domain Aggregate Source (Contracts/Domain/Note.cs)
// 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)

Generated Output (EF Core)
// <auto-generated/>
#nullable enable
namespace BitzOrcas.Sandbox.Infrastructure.Generated;
// Implement EF Core typed entity configuration interface
public 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)

Generated Output (SqlSugar)
// <auto-generated/>
#nullable enable
namespace BitzOrcas.Sandbox.Infrastructure.Generated;
// Statically configure SqlSugar entity metadata
public 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)

Generated Output (Native AOT Zero-Reflection Accessors)
// <auto-generated/>
#nullable enable
namespace BitzOrcas.Sandbox.Infrastructure.Generated;
// Closed generic static property accessors eliminating reflection overhead
public 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.

Value Object Flattening Example
// Declare immutable address value object
public 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, and Where predicates 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:

src/Modules/Ticket/Contracts/Domain/Ticket.cs
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:

Physical DatabaseORM Interceptor / AOPICommandRepositoryCommandHandlerPhysical DatabaseORM Interceptor / AOPICommandRepositoryCommandHandlerHot persistence pipeline pathSaveAsync(ticket)1. Generate 15-digit Snowflake Id (Yitter Snowflake -> Id)2. Inject trusted TenantId (from ICurrentTenant)3. Populate audit timestamp (IAppClock.UtcNow)4. Populate audit caller (ICurrentCaller.SubjectKey -> CreateId)5. Increment concurrency version (Version = Version + 1)Execute INSERT / UPDATESuccessResult.Success
  1. Snowflake Primary Key Generation:
    • When inserting an entity with Id == "0" or placeholder, IPersistenceIdGenerator produces a 15-digit safe Snowflake ID (epoch 2026-01-01, natively compatible with JavaScript Number.MAX_SAFE_INTEGER).
  2. Multi-Tenancy and Soft Delete Isolation:
    • EF Core uses HasQueryFilter; SqlSugar uses QueryFilter.AddTableFilter. Queries automatically append TenantId == @CurrentTenant AND IsDeleted == 0.
  3. Auditing and Clock Automation:
    • EF Core hooks into SaveChangesInterceptor; SqlSugar hooks into Aop.DataExecuting.
    • Timestamps derive strictly from IAppClock.UtcNow.
    • Operations record paired subject keys (CreateId/ModifyId) and display snapshots (CreateBy/ModifyBy).
  4. Modification Count Optimistic Concurrency:
    • Aggregate Version represents a strict modification counter: starts at 0, increments strictly by +1 on update, mapping version mismatches directly to Result.Failure(Error.Conflict).

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.

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%