Sandbox Golden Sample Architecture Breakdown
Within the BitzOrcas.Modern architectural system, src/Modules/Sandbox serves as the official Golden Use Case business module. It has zero external business dependencies and showcases a standalone domain module strictly adhering to the ADR 0203 physical isolation guidelines under rigorous production standards.
When implementing new business modules under src/Modules/ (such as Litigation or Contracts), Sandbox is the sole officially recommended golden reference pattern.
1. Three-Tier Physical Engineering Topology
The Sandbox module comprises three physical C# project files (.csproj), enforcing strict unidirectional dependencies and layer protection:
Layered Boundaries and Invariants
| Project Directory | Allowed Contents | Explicitly Forbidden Contents |
|---|---|---|
BitzOrcas.Sandbox.Contracts | Domain aggregate root (Domain/Note.cs), external DTOs (NoteDto.cs), strongly typed read ports (INoteReadStore.cs), module error catalog (SandboxErrors.cs), permission constants (SandboxPermissions.cs) | Specific ORM namespaces, database packages, HTTP context references, or reverse dependencies on Application or Infrastructure. |
BitzOrcas.Sandbox.Application | CQRS commands and handlers (Commands/Notes/*), queries and handlers (Queries/Notes/*), FluentValidation rules, assembly marker (SandboxApplicationAssembly.cs) | Inline raw SQL queries, cross-module physical repositories; write operations must depend only on ORM-neutral narrow command ports. |
BitzOrcas.Sandbox.Infrastructure | Implementation of contract ports (NoteReadStore.cs), IQueryShapeExecutorFactory pushdown projections, external client integrations | Private business rules bypassing Contracts; all adapters must be registered via typed attributes. |
2. Domain Aggregate Root & Metadata Isolation: Note.cs
In legacy enterprise codebases, developers often decorate domain entities with ORM-specific attributes (such as [SugarTable("T_xxx")] or [Table]), hardcode magic prefixes like T_, and expose public set accessors, degrading domain models into anemic data holders.
BitzOrcas.Modern enforces adapter-neutral metadata rules:
- Unified Aggregate Root Base: Inherits
TenantAggregateRoot<string>, standardizing tenant keys and entity IDs on distributed snowflakestringvalues, with automatic tenancy filtering, auditing, and soft deletion. - Standardized Table Naming: Uses adapter-neutral compile-time metadata
[BitzTable("SandboxNote", ...)]fromBitzOrcas.Persistence.Metadata. The table name is cleanly declared asSandboxNotewithout arbitraryT_prefixes. - Encapsulated State: Business properties use
private set, preventing external code from mutating fields outside aggregate domain boundaries. - Self-Validating Factory Method: Invariants are evaluated inside the
Note.Createstatic factory, returning a typedResult<Note>. - ORM Bypass Guard: The parameterless constructor is sealed with
[Obsolete("For ORM materialization only. Use Create.", error: true)]and[EditorBrowsable(EditorBrowsableState.Never)], causing compile-time errors if business code attempts direct instantiation.
using System.ComponentModel;using BitzOrcas.Domain.Contracts;using BitzOrcas.Domain.Entities;using BitzOrcas.Domain.Results;using BitzOrcas.Domain.Tenancy;using BitzOrcas.Persistence.Metadata;using BitzOrcas.Sandbox.Contracts;
namespace BitzOrcas.Sandbox.Domain;
/// <summary>/// Note owner-private aggregate root/// </summary>/// <remarks>/// Demonstrates aggregate invariants and cross-cutting contracts (ITenantEntity/ISoftDelete/IAuditableEntity/IConcurrencyTracked)./// Persistent IDs and TenantId both use string; auditing, soft-delete, and concurrency fields are provided by the tenant aggregate base./// This class declares adapter-neutral compile-time metadata; the persistence layer materializes this aggregate directly./// </remarks>[BitzTable("SandboxNote", IsTenant = true, IsSoftDelete = true, Description = "Sandbox Note")]public sealed class Note : TenantAggregateRoot<string>{ /// <summary> /// Maximum allowed title character length /// </summary> public const int TitleMaxLength = 200;
/// <summary> /// Note title /// </summary> [BitzColumn(ColumnName = "Name", Length = TitleMaxLength, IsRequired = true)] public string Title { get; private set; }
/// <summary> /// Parameterless constructor reserved exclusively for ORM materialization /// </summary> /// <remarks> /// Establishes valid placeholder state for persistence hydration. ORM engines require a public parameterless constructor; /// ObsoleteAttribute with error contract blocks business code at compile time, /// while EditorBrowsableAttribute hides it from IDE completion lists. /// </remarks> [Obsolete("For ORM materialization only. Use Create.", error: true)] [EditorBrowsable(EditorBrowsableState.Never)] public Note() : base("0") { Title = "Materialized"; }
/// <summary> /// Private constructor invoked by the factory method /// </summary> private Note(string id, string title, string tenantId) : base(id) { Title = title; TenantId = tenantId; }
/// <summary> /// Factory method to construct and validate a Note aggregate /// </summary> /// <param name="title">Raw input title.</param> /// <param name="tenantId">Trusted tenant identifier from security context.</param> /// <returns>Success with aggregate instance; failure with typed domain error.</returns> public static Result<Note> Create(string? title, string? tenantId) { var normalizedTitle = title?.Trim(); if (string.IsNullOrWhiteSpace(normalizedTitle) || normalizedTitle.Length > TitleMaxLength) { return Result<Note>.Failure(SandboxErrors.NoteTitleInvalid); }
if (string.IsNullOrWhiteSpace(tenantId) || !TenancyDefaults.IsValid(tenantId)) { return Result<Note>.Failure(SandboxErrors.NoteTenantInvalid); }
var note = new Note("0", normalizedTitle, tenantId); return Result<Note>.Success(note); }}3. Strongly Typed Error Catalog: SandboxErrors.cs
BitzOrcas forbids throwing unstructured business exceptions or relying on magic strings. Operational failures are declared statically within the contract layer, explicitly typed as Validation, NotFound, Unauthorized, or Failure:
using BitzOrcas.Domain.Results;
namespace BitzOrcas.Sandbox.Contracts;
/// <summary>/// Stable strongly typed error catalog for the Sandbox module/// </summary>public static class SandboxErrors{ /// <summary> /// Note title invalid (empty or exceeds 200 characters) /// </summary> public static readonly Error NoteTitleInvalid = Error.Validation("Sandbox.Note.TitleInvalid");
/// <summary> /// Note tenant identifier invalid /// </summary> public static readonly Error NoteTenantInvalid = Error.Validation("Sandbox.Note.TenantInvalid");
/// <summary> /// Current execution context missing trusted tenant information /// </summary> public static readonly Error TenantRequired = Error.Unauthorized("Sandbox.Tenant.Required");
/// <summary> /// Requested Note does not exist /// </summary> public static readonly Error NoteNotFound = Error.NotFound("Sandbox.Note.NotFound");
/// <summary> /// Page index out of valid range (must start from 1) /// </summary> public static readonly Error NoteInvalidPage = Error.Validation("Sandbox.Note.InvalidPage");
/// <summary> /// Page size out of valid range /// </summary> public static readonly Error NoteInvalidPageSize = Error.Validation("Sandbox.Note.InvalidPageSize");
/// <summary> /// Search query string exceeds length quota /// </summary> public static readonly Error NoteSearchTextInvalid = Error.Validation("Sandbox.Note.SearchTextInvalid");
/// <summary> /// Query execution failed on underlying persistence store /// </summary> public static readonly Error NoteQueryFailed = Error.Failure("Sandbox.Note.QueryFailed");}4. CQRS Write Path Single-File Vertical Slice: CreateNoteCommand.cs
The write path strictly follows Single-File Vertical Slice Architecture: command contract, HTTP route generator attribute, MCP tool registration, authorization descriptors, and execution handler are unified in one compilation unit.
The handler depends only on the ORM-neutral narrow repository port ICommandRepository<Note, string>:
using BitzOrcas.Application.Abstractions.Tenancy;using BitzOrcas.Application.Authorization;using BitzOrcas.Domain.Abstractions;using BitzOrcas.Domain.Results;using BitzOrcas.Endpoint.Attributes;using BitzOrcas.Mcp.Attributes;using BitzOrcas.Sandbox.Contracts;using BitzOrcas.Sandbox.Domain;using Mediator;
namespace BitzOrcas.Sandbox.Application.Commands.Notes;
/// <summary>/// Create Note command contract/// </summary>[GenerateEndpoint(HttpRoute.Post, "/api/notes/", Tag = "Notes")][GenerateMcpTool("CreateNote", "Create a new note with validated title")]public sealed record CreateNoteCommand(string Title) : ICommand<Result<string>>, IAuthorizedRequest{ /// <summary> /// Protected resource: sandbox module note resource /// </summary> public ResourceDescriptor Resource { get; } = new( SandboxPermissions.Module, SandboxPermissions.NoteResource);
/// <summary> /// Authorization action: Create /// </summary> public AuthorizationAction Action { get; } = AuthorizationAction.Create;}
/// <summary>/// Application orchestration handler for creating Note instances/// </summary>public sealed class CreateNoteCommandHandler( ICommandRepository<Note, string> repository, ICurrentTenant currentTenant) : ICommandHandler<CreateNoteCommand, Result<string>>{ /// <summary> /// Validates invariants and persists the Note aggregate in current transaction scope /// </summary> public async ValueTask<Result<string>> Handle( CreateNoteCommand request, CancellationToken cancellationToken) { var tenant = currentTenant.Tenant; if (!tenant.IsAvailable) { return Result<string>.Failure(SandboxErrors.TenantRequired); }
// Delegate invariant verification to domain aggregate factory var noteResult = Note.Create(request.Title, tenant.EffectiveTenantId); if (noteResult.IsFailure) { return Result<string>.Failure(noteResult.Error); }
var note = noteResult.GetValueOrThrow(); var saveResult = await repository.SaveAsync(note, cancellationToken); return saveResult.IsFailure ? Result<string>.Failure(saveResult.Error) : Result<string>.Success(note.Id); }}5. CQRS Read Path Narrow Port & Persistence Adapter
To prevent repository interface bloat and avoid leaking raw ORM entities or IQueryable to API consumers, BitzOrcas enforces narrow read ports with pushdown execution:
- Contracts Exposes Narrow Read Ports:
INoteReadStorereturns only immutableNoteDtoandPagedResult<NoteDto>. - Infrastructure Carries Concrete Implementation: Marked with
[RegisterPersistenceAdapter<INoteReadStore>]for automatic DI registration. - QueryShape Pushdown: List queries leverage
IQueryShapeExecutorFactoryto push filtering and pagination down to the database engine with automatic tenant boundary safety.
Contract Narrow Port Definition (INoteReadStore.cs)
using BitzOrcas.Domain.Abstractions.Queries;using BitzOrcas.Domain.Results;
namespace BitzOrcas.Sandbox.Contracts;
/// <summary>/// Note narrow read-model store port/// </summary>public interface INoteReadStore{ /// <summary> /// Reads Note read model by identifier /// </summary> Task<Result<NoteDto>> GetByIdAsync(string id, CancellationToken cancellationToken = default);
/// <summary> /// Searches Note read models by criteria for current tenant /// </summary> Task<Result<PagedResult<NoteDto>>> SearchAsync( NoteListFields fields, PaginationParams paging, SortRequest? sort, CancellationToken cancellationToken = default);}Infrastructure Persistence Adapter (NoteReadStore.cs)
using BitzOrcas.Application.Abstractions.Tenancy;using BitzOrcas.DI.Attributes;using BitzOrcas.Domain.Abstractions;using BitzOrcas.Domain.Abstractions.Queries;using BitzOrcas.Domain.Results;using BitzOrcas.Infrastructure.Queries;using BitzOrcas.Sandbox.Contracts;using BitzOrcas.Sandbox.Domain;using Microsoft.Extensions.Logging;
namespace BitzOrcas.Sandbox.Infrastructure;
/// <summary>/// Persistence adapter implementing Note read-model store/// </summary>[RegisterPersistenceAdapter<INoteReadStore>]public sealed class NoteReadStore( ICommandRepository<Note, string> notes, IQueryShapeExecutorFactory executorFactory, ICurrentTenant currentTenant, ILogger<NoteReadStore> logger) : INoteReadStore{ public async Task<Result<NoteDto>> GetByIdAsync(string id, CancellationToken cancellationToken = default) { var noteResult = await notes.FindAsync(id, cancellationToken).ConfigureAwait(false); if (noteResult.IsFailure) { return noteResult.Error.Type == ErrorType.NotFound ? Result.Failure<NoteDto>(SandboxErrors.NoteNotFound) : Result.Failure<NoteDto>(noteResult.Error); }
var note = noteResult.GetValueOrThrow(); return Result.Success(new NoteDto(note.Id, note.Title)); }
public async Task<Result<PagedResult<NoteDto>>> SearchAsync( NoteListFields fields, PaginationParams paging, SortRequest? sort, CancellationToken cancellationToken = default) { var tenant = currentTenant.Tenant; if (!tenant.IsAvailable) { return Result.Failure<PagedResult<NoteDto>>(SandboxErrors.TenantRequired); }
try { var input = NoteListInput.From(fields, paging); return await input.ExecutePageAsync( executorFactory, static row => new NoteDto(row.NoteId, row.Title), sorts: sort.WithTieBreaker(nameof(NoteListInput.NoteId)), cancellationToken: cancellationToken) .ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception exception) when (exception is not OutOfMemoryException) { logger.LogError( "Sandbox Note query failed with {ExceptionType}.", exception.GetType().Name); return Result.Failure<PagedResult<NoteDto>>(SandboxErrors.NoteQueryFailed); } }}6. Related Architecture Decisions & Recommended Reading
- Module Lifecycle: BitzOrcas.Modularity Lifecycle & Wiring
- Profile Delivery: Profiles & Metapackages Tailoring Architecture
- Architecture Decision: ADR 0203: Framework & Modules Physical Directory Structure