BitzOrcas.Domain is the innermost shared building block. It registers no DI, starts no middleware, and references no ORM. Business modules use its types to express identity, state boundaries, failure semantics, and domain facts.
Entity and aggregate hierarchy
Entity<TId> ├─ Id ├─ CreateTime / ModifyTime / CreateBy / ModifyBy / ImpersonatorId └─ DomainEvents + Raise() + ClearDomainEvents() │ ▼AggregateRoot<TId> ├─ IsDeleted / DeleteTime / DeleteBy └─ Version ├─ TenantAggregateRoot<TId> + TenantId └─ PlatformAggregateRoot<TId>Entity<TId>
Entities compare by concrete type and Id. The base type implements IAuditableEntity and collects pending domain events. If a new object starts with a placeholder identifier, the persistence pipeline can assign the final value through AssignPersistenceId.
Business code should not freely set audit properties. They are cross-cutting state maintained by persistence and caller context.
AggregateRoot<TId>
An aggregate root is a consistency and transaction boundary. On top of Entity it implements:
ISoftDelete:IsDeleted,DeleteTime, andDeleteBy;IConcurrencyTracked: optimistic-concurrencyVersion.
Outside code changes internal state through aggregate behavior instead of holding and mutating internal entities directly.
Tenant and platform aggregates
Tenant-owned business data derives from TenantAggregateRoot<TId> and gains a string TenantId. Platform-owned data derives from PlatformAggregateRoot<TId>. This is an ownership decision, not a shortcut for adding a property.
A tenant value supplied by a request is untrusted. Aggregate creation uses the current user, system job, or another validated tenant scope.
Result and Error
Expected business failures return Result or Result<T> rather than throwing:
// ① Focus on the contract and control flow; keep validation, cancellation, and typed errors explicit.public static Result<Note> Create(string title, string tenantId){ // ② The aggregate factory protects its invariant and reports expected failure with a stable Error. if (string.IsNullOrWhiteSpace(title)) { return Result<Note>.Failure(SandboxErrors.NoteTitleInvalid); }
return Result<Note>.Success(new Note(title, tenantId));}Error contains a stable Code, a diagnostic Description, and an ErrorType. Current categories and default HTTP semantics are:
ErrorType | Typical meaning | HTTP |
|---|---|---|
Validation | Format, required value, length, or enum range | 400 |
NotFound | Resource does not exist | 404 |
Conflict | Concurrency, idempotency, or state conflict | 409 |
Forbidden | Authenticated but not permitted | 403 |
Unauthorized | Not authenticated | 401 |
Failure | Use-case business rule not satisfied | 422 |
Unexpected | Fallback for an unexpected system failure | 500 |
There is no Infrastructure error category. A database outage and other unexpected failures use the exception and Problem Details path unless an external operation defines a specific recoverable business contract.
Description is not localized end-user copy. APIs and clients map stable error codes to display text.
Domain events
A domain event is a fact that has already happened:
// ① Focus on the contract and control flow; keep validation, cancellation, and typed errors explicit.[IntegrationTopic("note.created")]public sealed record NoteCreatedDomainEvent( string TenantId, string Title) : DomainEvent;The aggregate calls Raise(event), Application tracks it through IDomainEventCollector, and DomainEventDispatchPipelineBehavior dispatches under the transaction contract. [IntegrationTopic] sends an event into the integration-publishing path.
Keep the boundary clear:
- name events in the past tense;
- carry stable facts needed by consumers, not ORM entities;
- let aggregate construction or state changes raise the event; the Handler does not duplicate it;
- restoring an aggregate from storage must not raise a new “created” event.
Value objects and SmartEnum
ValueObject provides value equality for concepts without identity, such as EmailAddress. Modules define types such as NoteTitle, money, or ranges to centralize validation and normalization.
SmartEnum<TEnum,TValue> models a finite set that needs stable names/values plus behavior. Persist it through explicit scalar properties and compile-time generated converters. Unknown stored values should fail closed under the relevant business contract.
The W0 list-query contract adds four shared filter value-object families:
| Type | Current semantics | Invalid input |
|---|---|---|
DateRange | A closed time range with either side optional; a date-only upper bound expands to the last tick of that day | A lower bound after the upper bound returns DateRange.Invalid |
AmountRange | A closed monetary range backed by decimal | Min > Max returns NumberRange.Invalid |
NumberRange<TNumber> | A closed range for comparable values such as quantity, rating, or percentage | Min > Max returns NumberRange.Invalid |
EnumFilterSet<TEnum> | A deduplicated, ordered, frozen enum set whose equality ignores input order | null becomes an empty set |
Their creation APIs protect invariants; callers cannot construct an invalid state through public setters. The DateRange JSON converter also calls the factory. Database filters use its exclusive upper bound, while display code and Contains use the closed range. See the list-query and export-range contract for the HTTP envelope, paging, and QueryShape expansion rules.
Platform hard limits
GlobalPlatformConstants holds stable, cross-module limits that a tenant cannot configure. PagingLimits remains the source of truth for online-list paging: 20 by default and 1000 at most; GlobalPlatformConstants.Pagination only aliases those values. Background workers use the separate Batch.PageSize=2000, which cannot widen an HTTP request.
Shared text boundaries are 128 characters for stable identifiers, 1000 for short descriptions, and 2000 for remarks and audit reasons. Remote options at 50, Workflow QueryStore at 200, and SCIM at 200 remain protocol- or engine-specific limits rather than global online paging values.
Clock
IAppClock is the single way Domain and Application code read “now.” The default SystemClock reads an injected TimeProvider. Business code must not read DateTime.Now, DateTime.UtcNow, DateTimeOffset.UtcNow, or TimeProvider.System directly. Prefer passing a timestamp into aggregate behavior; inject a domain service only when it must read time repeatedly.
| API | Type | Current semantics | Use |
|---|---|---|---|
UtcNow | DateTimeOffset | UTC from the injected provider | audit, tokens, expiry, domain events, and security windows |
Instant | NodaTime Instant | absolute instant converted from UtcNow | cross-time-zone schedules, financial dates, and NodaTime models |
Now | DateTime | UTC with Kind=Utc | compatibility with existing DateTime boundaries |
ToStorage(value) | DateTime | normalizes an absolute instant to UTC storage | legacy repository writes |
FromStorage(value) | DateTimeOffset | restores storage as UTC; historical Unspecified values are also interpreted as UTC | legacy repository reads |
Clock:Kind defaults to Utc. Local, Unspecified, and Clock:TimeZoneId currently remain for configuration compatibility; ClockNormalize still reads and writes UTC. Setting Kind=Local therefore does not promise local wall-clock storage. Convert for display only at an API or frontend boundary, never inside audit or persistence facts.
The composition root registers TimeProvider.System, and tests can replace it with a controllable provider. SystemClock keeps reading that provider; FixedClock(TimeProvider) captures it once at construction. DateTimeExtensions.IsPast, IsFuture, and Elapsed also expose TimeProvider overloads for deterministic tests.
// Read one absolute timestamp in the use case and pass it into aggregate behavior.var occurredAt = clock.UtcNow;var result = aggregate.Approve(occurredAt, currentUser.UserId);
// Replace the time source in test composition without changing production code.services.AddSingleton<TimeProvider>(new MutableTimeProvider(fixedUtcNow));Usage boundaries
- Domain may reference these primitives and its module’s own Contracts.
- Domain does not reference Mediator handlers,
HttpContext, an ORM, CAP, Redis, or configuration. [BitzTable]/[BitzColumn]are provider-neutral declarations read at compile time; Domain does not call provider APIs.- Do not move shared business policy into Core. Core shares stable technical semantics; modules own business language.
Design-review checklist
- Does the aggregate define a consistency boundary rather than mechanically wrap one table?
- Do all expected failures return a stable
Error.Codeand remain separate from unexpected exceptions? - Do creation and state transitions protect invariants instead of exposing public setters?
- Does tenant-owned data use the correct base type and receive
TenantIdfrom trusted context? - Does each domain event describe a completed fact and carry only stable payload?
- Does restore avoid raising creation events again or overwriting audit state?
- Do callers or explicit ports provide time, current user, and randomness?
- Are unknown values, serialization, and persistence of value objects and SmartEnums tested?
- Do online paging, background batches, and protocol exceptions use their own named limits instead of one reused magic number?
Source and verification entry points
Core types live under src/Framework/BitzOrcas.Domain; clock implementations live under src/Framework/BitzOrcas.Infrastructure/Clocking; domain-event collection and pipeline behavior live under src/Framework/BitzOrcas.Application. ApplicationClockTests covers clock behavior. PaginationParamsTests, DateRangeTests, and RangeValueObjectTests cover the query value objects. After changing a core primitive, run Domain unit tests, public API/architecture tests, and the persistence contract tests for affected modules.