Skip to content

Explicit artifact registration

Use ChronicleOptions.ExplicitArtifacts when your contract types cannot reference Chronicle or you want to nominate artifacts at the composition root. Existing attributes and interfaces keep working. A plain type is not discovered merely because its methods look like handlers: nominate it explicitly.

The generic and Type forms declare the same artifacts:

Generic formRuntime-type formOptional configuration
RegisterEventType<T>(configure)RegisterEventType(type, configure)Event identity, generation, visibility, owning store, publication sequence
RegisterReactor<T>(id)RegisterReactor(type, id)ReactorId
RegisterReducer<T>(id)RegisterReducer(type, id)ReducerId
RegisterConstraint<T>()RegisterConstraint(type)None
RegisterEventSource<T>(name)RegisterEventSource(type, name)Source name
RegisterMigration<T>()RegisterMigration(type)None

Every method returns the same ExplicitArtifacts object. The configuration arguments are optional. Existing projection, model-bound read-model and delegate-reactor registration remain available on that object; see explicit read-model registration.

The composition example keeps event records plain while registering their observers and constraints.

Omitted arguments inherit marker declarations, then the existing identity defaults:

  • Event id: the CLR type’s name; generation: 1.
  • Reactor and reducer id: the full CLR type name, with the existing namespace/name fallback.
  • Event source name: the CLR type name with an EventSource suffix removed.
  • Constraint names: the existing constraint-builder or attribute naming rules.

Discovery and explicit registration are peers, not an override stack. Equal declarations for the same CLR type coalesce. Incompatible supplied ids, generations, owning stores, visibility or publication destinations throw ConflictingArtifactDeclaration, including declaration provenance. A marked id cannot be silently replaced by an explicit alias. Two CLR types with the same (id, generation) are rejected; distinct generations of one id are valid.

A type explicitly nominated as both reactor and reducer, or against a contradictory observer marker, throws ConflictingArtifactKind. Invalid nominated shapes fail preparation rather than being silently omitted. Local identity, schema or migration failures prevent registration effects; an independently invalid projection retains the existing partial-projection outcome behavior.

Builder methodMeaning
WithId(EventTypeId)Stable event identifier
Generation(EventTypeGeneration)Numeric generation
Public()Public registration visibility and publication eligibility
Private()Private registration visibility, without subtracting publication eligibility
ForEventStore(EventStoreName)Owning store
PublishTo(EventSequenceId)Destination sequence for event-target publication

Registration visibility and publication eligibility are separate. Ownership in the current store, [Public], or explicit Public() can grant eligibility; those contributions form a union. Private() never removes ownership-derived eligibility. It conflicts with a supplied public visibility declaration, but not with an ownership-derived default. Declared store ownership, including a foreign store, defaults registration visibility to public. Foreign ownership alone does not grant publication eligibility.

Absent visibility and sequence attributes supply no competing declaration. You can therefore add Public() or PublishTo(customSequence) to an otherwise marked event without conflicting with default private/outbox values. PublishTo alone does not make an event eligible for publication, and the event log is not a valid publication destination. The default destination is the outbox.

Numeric Generation(n) does not mean “historical type.” Exact-dispatch exclusion remains the behavior of [EventTypeGenerationFor<T>]; its referenced identity resolves through the completed peer declarations.

Each store captures all explicit recipes together when it is constructed. Later options changes affect only subsequently created stores. Reconnect reuses that snapshot. Explicit DiscoverAll() can refresh the discovered provider while retaining the original recipes.

AutoDiscoverAndRegister = false stays manual: call DiscoverAll() to apply options recipes, then RegisterAll() when connected. An untouched manual store does not silently acquire those recipes. This differs from a fixture’s AutoDiscoverArtifacts = false, which disables assembly discovery but does not disable its client’s registration process.

An empty explicit snapshot leaves the supplied provider untouched. With nominations, the store merges a local catalog without mutating the provider. Attribute-derived Unique, property-Unique, RemoveConstraint and ClosesStream entries are added only for explicitly nominated events and unioned without duplicates. For discovered-only events, a custom provider’s own derived lists remain authoritative, including intentional omissions.

A nominated reactor uses the existing instance-method dispatch, dependency injection, filters, tags, sequence selection and replay behavior. A nominated reducer infers exactly one read model from supported instance (event, current[, EventContext]) methods. The current read model must be nullable. Supported returns remain the read model, Task<read model> and classic Task; classic mutation-only Task keeps its existing null-return semantics. Static self-reducing models are not part of this API.

A plain constraint definer exposes exactly one public synchronous instance void Define(IConstraintBuilder) method. Existing IConstraint implementations keep their direct interface path. A nominated event source keeps its declared streams and metadata; an explicit name changes the registered identity, not its declaration-only description helpers.

The existing constrained generic observer APIs remain. For plain classes, import Cratis.Chronicle.Reactors or Cratis.Chronicle.Reducers to use the registered-artifact extension siblings, or pass typeof(YourObserver) to the Type-based operations. These operations resolve aliases through the store. Plain Register(Type) is explicit intent and need not be prelisted in options; its event types must already be registered.

Custom observer registries that implement only the old generic interface surface remain compatible. Unsupported new Type operations throw TypeBasedArtifactRegistrationNotSupported rather than pretending registration succeeded.

Explicit migration registration uses the existing migration builders and kernel-side generation engine.

For unmarked or aliased endpoints, a typed migration calls the additive protected constructor EventTypeMigration<TUpgrade,TPrevious>(IEventTypes). The client supplies the owning store’s finalized registry, even if ordinary DI contains another registry. Both endpoints must be registered, share one id and have adjacent generations.

The parameterless base constructor keeps declaration-only validation. An attributed previous endpoint intentionally omitted by a custom provider remains valid on that legacy path; its placeholder schema behavior is unchanged. Merely listing a legacy migration explicitly does not switch its constructor semantics.

A nominated plain, concrete, closed class declares these roles:

  • public void Upcast(IEventMigrationBuilder<TUpgrade,TPrevious> builder)
  • public void Downcast(IEventMigrationBuilder<TPrevious,TUpgrade> builder)
  • Optional public void MapValues(IEventValueMapBuilder<TUpgrade,TPrevious> builder)

Each role is one non-generic synchronous instance method with one ordinary parameter. Overloads, ambiguous endpoints, static methods, async void and malformed roles are rejected. Both endpoints must be registered. The adapter uses the typed migration engine, including bidirectional value-map inversion; there is no separate migration language. Constructor dependencies use ordinary DI, with an owning registry supplied explicitly when requested. Plain definers must not implement disposal interfaces because the migration provider has no disposal ownership contract.

Every explicit recipe is validated during preparation, even if no discovered upgrade could otherwise select it. Migration nomination does not register missing events automatically. Custom migration providers retain compatible legacy output; opaque results that require a new registry binding but cannot identify their original recipe fail with ExplicitMigrationBindingNotSupported. Plain adapter activation makes no new trimming or Native AOT guarantee. The migration-id analyzer does not prove plain-role endpoints; runtime validation does.

Compensation metadata can refer to explicitly registered targets. Built-in schema generation resolves new aliases or unmarked compensation targets through the owning registry without placing store-specific ids in CLR-only caches. Those new targets must be registered.

A custom IJsonSchemaGenerator retains its original output for declaration-only compensation, even when the marked target is absent from the registry. For new explicit bindings it must implement the additive Generate(Type, IEventTypes) and GenerateLegacyEventType(Type, IEventTypes) overloads. Otherwise RegistryAwareEventTypeSchemasNotSupported fails narrowly on that requirement; no metadata is silently rewritten.

For concurrency scopes, pass store.EventTypes.GetEventTypeFor(typeof(YourEvent)) to existing WithEventType(EventType) or WithEventTypes methods. Declaration-only Type/generic helpers cannot know an explicit alias. For a source alias, resolve store.EventSources.GetFor(typeof(YourSource)) and its FindStream(...) definition, then use their effective EventSourceType and EventStreamType with the existing scope builders.

Per-run Defaults(artifacts, serviceProvider, options) honors the same registrations in ReadModelScenario, ReactorScenario and EventScenario. A plain reactor scenario requires explicit nomination; it is not shape-discovered. Give each independent run its own defaults/options rather than changing Defaults.Instance.

XUnit integration options have the lifetime of the fixture type’s host/client/store, not an individual test run. Configure explicit recipes before that host is created. Reuse keeps its first captured snapshot; a separately created fixture host has a separate snapshot. The sharing boundary is the existing closed ChronicleClientFixture<TChronicleFixture> family, so derived scenarios sharing that family also share its host. A fixture that overrides ChronicleFixture.UsesPerContextMongoDBHost to return true starts a fresh host for each test context, so each context captures its own snapshot. Disabling fixture assembly discovery does not remove explicit recipes. See testing configuration.

Analyzer evidence recognizes possible registrations in the same compilation, not execution or per-client membership. Runtime validation is final authority.