This page captures the technical/framework rules enforced by this library. The library holds no domain or business logic of its own; these rules govern its data-access infrastructure (unit-of-work lifecycle, session caching, engine selection, and options defaults).
BR-NH-01 Mapping assemblies fixed at registration¶
Field
Value
Statement
A factory sees only the assemblies passed to AddNhibernateServices at registration time; mapping assemblies cannot be changed afterward.
Trigger
Host calls AddNhibernateServices(assemblies, debugOutputSql) or AddNhibernateServices<T>(debugOutputSql).
Enforcement
IServicesCollectionExtensions.AddUnitOfWorkFactory captures the assemblies in the UnitOfWorkFactory constructor closure.
Outcome
The singleton UnitOfWorkFactory scans the same fixed assembly set for every session factory it builds for the life of the process.
BR-NH-02 Options applied only to matching named instance¶
Field
Value
Statement
A ConfigureUnitOfWorkOptions subclass writes Name, Database, and ConnectionString only when the resolved options name equals its own UniqueName.
Trigger
IOptionsSnapshot<UnitOfWorkOptions>.Get(name) on a cache miss for that name runs every registered IConfigureNamedOptions<UnitOfWorkOptions>.Configure(name, options); a cache hit for an already-resolved name invokes no Configure call.
Enforcement
ConfigureUnitOfWorkOptions.Configure(string, UnitOfWorkOptions) guards the assignment with a name-equality check.
Outcome
Every registered configurer whose UniqueName matches the requested name applies its values; nothing in source prevents two different registered ConfigureUnitOfWorkOptions subclasses from declaring the same UniqueName, and if that happens, registration order determines which values are ultimately kept (last-registered-wins for overlapping properties, since .NET's named-options pipeline runs every matching Configure call in registration order).
BR-NH-03 Connection string resolved once at construction¶
Field
Value
Statement
The connection string is resolved once, when the configurer is constructed, not on every Configure call.
Trigger
DI constructs the ConfigureUnitOfWorkOptions subclass (typically once per IOptionsSnapshot scope).
Enforcement
The base constructor calls the abstract GetConnectionString() and caches the result in a private field.
Outcome
Repeated Configure calls reuse the cached string rather than re-resolving it; a GetConnectionString() exception surfaces at construction time, before any options are read.
BR-NH-05 Session factories cached by connection string¶
Field
Value
Statement
NHibernate ISessionFactory instances are cached process-wide in a static dictionary keyed only by connection string.
Trigger
UnitOfWorkFactory.GetUnitOfWork is called for any options instance.
Enforcement
SessionFactories.GetOrAdd(options.ConnectionString, _ => BuildSessionFactory(options)) under a global lock.
Outcome
Two named options sharing a connection string share one session factory, and therefore share whichever BatchSize/ShowSql/DriverFormatSql/mapping set was captured when the factory was first built.
BR-NH-06 INhConfiguration wrapper cached by name but wrapped NHibernate Configuration rebuilt per cache miss¶
Field
Value
Statement
UnitOfWorkFactory caches the INhConfiguration wrapper per factory instance, keyed by the options Name, separately from the SessionFactories cache; the NHibernate Configuration object that wrapper produces is not itself cached — it is rebuilt every time BuildSessionFactory runs for an outer SessionFactories cache miss.
Trigger
UnitOfWorkFactory.BuildSessionFactory / CreateConfiguration run for a given options name, invoked whenever GetUnitOfWork misses the SessionFactories cache for that options' connection string.
Enforcement
Configuration.GetOrAdd(options.Name, _ => CreateConfiguration(options)) under a lock on the instance-level Configuration dictionary returns the same INhConfiguration wrapper for a given name, but BuildSessionFactory then calls config.Configure(this.Mappings) unconditionally; BaseConfiguration.Configure calls GetConfiguration(), which always runs Fluently.Configure().Database(ConfigureDriver()).Mappings(mappingBuilder) followed by .BuildConfiguration() and assigns the result to a new Configuration object before re-applying ConnectionString/BatchSize/ShowSql.
Outcome
The INhConfiguration wrapper instance (and the engine selection it was constructed with) is built once per name per factory instance and reused on subsequent calls with the same name, but the underlying NHibernate Configuration object it wraps is rebuilt from scratch on every SessionFactories cache miss — the wrapper cache hitting does not mean the inner NHibernate Configuration is cached too.
BR-NH-09 SQL debug interceptor applies only to stateful sessions while debugging¶
Field
Value
Statement
The SQL debug interceptor is attached only to stateful sessions when debugOutputSql = true, and it emits output only while a debugger is attached.
Trigger
UnitOfWorkFactory.BuildSessionStrategy opens a stateful session with debugOutputSql set.
Enforcement
factory.WithOptions().Interceptor(new SqlDebugOutputInterceptor()).OpenSession() for the stateful + debug path; SqlDebugOutputInterceptor.OnPrepareStatement checks Debugger.IsAttached before writing to Trace.
Outcome
Stateless sessions never receive SQL tracing regardless of debugOutputSql; stateful sessions only trace when a debugger is actually attached to the process.
BR-NH-10 Session type selected by StatelessSessionStrategy option¶
Field
Value
Statement
The session strategy (stateful vs. stateless) is chosen per unit of work by UnitOfWorkOptions.StatelessSessionStrategy, which defaults to false (stateful).
Trigger
UnitOfWorkFactory.BuildSessionStrategy runs for every GetUnitOfWork call.
Enforcement
A boolean check on options.StatelessSessionStrategy selects StatelessSessionStrategy or StatefulSessionStrategy.
Outcome
Consumers get change-tracked stateful sessions unless they explicitly opt into stateless, bulk-throughput sessions.
BR-NH-11 Transactions always use ReadCommitted isolation¶
Field
Value
Statement
UnitOfWork.Begin always opens a transaction at ReadCommitted isolation; the stateful strategy ignores any isolation level argument it is given.
Trigger
UnitOfWork.Begin() is called.
Enforcement
Begin hard-codes IsolationLevel.ReadCommitted; StatefulSessionStrategy.BeginTransaction ignores its own isolation-level parameter and also always uses ReadCommitted.
Outcome
No consumer calling UnitOfWork.Begin() — the standard entry point — can obtain a different isolation level, because Begin hard-codes the argument and the stateful strategy ignores it regardless. This does not extend to every possible call path: the stateless strategy's BeginTransaction(isolationLevel) honors whatever level is passed to it directly (session.BeginTransaction(isolationLevel)), so a consumer calling ISessionStrategy.BeginTransaction directly on a stateless session, bypassing UnitOfWork.Begin(), can select a different isolation level.
BR-NH-12 Begin refused while a transaction is active¶
Field
Value
Statement
Begin throws InvalidOperationException("There is a transaction already in progress.") when the session's current transaction is still active (begun and not yet committed or rolled back). It does not limit a unit of work to one transaction.
Trigger
UnitOfWork.Begin() is called while a transaction started by an earlier Begin() has not yet been committed or rolled back.
Enforcement
IsTransactionInProgress() reads Session.Transaction() on every call and checks IsActive \|\| WasCommitted \|\| WasRolledBack on that transaction. Against NHibernate 5.2.7 (resolved transitively via FluentNHibernate 3.0.1), a successful Commit or a Rollback detaches the completed transaction from the session (ConnectionManager.AfterTransaction() sets _transaction = null), and the next Session.Transaction read returns a new, un-begun transaction. In practice the guard therefore rejects only an active transaction; the WasCommitted/WasRolledBack checks do not fire on that standard path. Session.Transaction never returns null in NHibernate 5.2.7.
Outcome
A second Begin() while a transaction is active throws. After a successful Commit() the session stays open, and a further Begin() on the same unit of work starts a new transaction instead of throwing. Using a new unit of work for each transaction is recommended, but the library does not enforce it.
Commit throws InvalidOperationException if no transaction is active; a successful commit also calls Session.Clear(), which clears the first-level cache on the stateful strategy but is a no-op on the stateless strategy (which has no first-level cache and already writes changes immediately).
Trigger
UnitOfWork.Commit() is called.
Enforcement
A guard checks for an active transaction before committing; on success, Session.Clear() runs after the commit (stateful: discards cache; stateless: no-op).
Outcome
Callers must call Begin() before Commit(); on the stateful strategy, committed state is not retained in the session's identity map afterward. On the stateless strategy, there is no identity map to retain state in, and Commit() is effectively a COMMIT-only operation on an already-immediately-written set of changes.
BR-NH-14 Rollback disposes the session only when rollback itself does not throw¶
Field
Value
Statement
Rollback rolls back the active transaction, if any, and then in the normal (non-throwing) case disposes the session, regardless of whether a transaction was active.
Trigger
UnitOfWork.Rollback() is called.
Enforcement
The method conditionally rolls back, then disposes the session — but there is no try/finally around the rollback: if transaction.Rollback() itself throws, Session.Dispose() on the next line is skipped entirely. Rollback() also does not set the isDisposed flag, so a later Dispose() call still runs full disposal.
Outcome
When transaction.Rollback() does not throw, the session is disposed. Whether a unit of work can still be used afterward is not fully characterized by this rule alone: the stateful session strategy can transparently reopen a closed session on next access (see ISessionStrategy), so "disposed" does not by itself prove every later call fails. When transaction.Rollback() throws, session disposal is skipped and the exception propagates; neither a left-open session nor a specific failure mode for subsequent calls is proven by this library's own source in that case.**
BR-NH-15 Add Update and Load vary by session strategy¶
Field
Value
Statement
Repository CRUD semantics differ by session strategy: stateful Add upserts via SaveOrUpdate, stateless Add always inserts via Insert; stateful Load calls ISession.Load, stateless Load calls IStatelessSession.Get. ISession.Load returns a lazy, no-round-trip proxy only when the entity is mapped with a proxy — a consumer mapping choice this library does not configure; with no proxy mapping, Load behaves like an eager fetch.
Trigger
Repository<TEntity>.Add(entity) or Repository<TEntity>.Load(id) is called.
Enforcement
StatefulSessionStrategy and StatelessSessionStrategy map each IRepository<TEntity> member to different underlying ISession/IStatelessSession calls.
Outcome
Consumers switching StatelessSessionStrategy on an existing repository usage must account for upsert-vs-insert differences, and must not assume stateful Load is always a deferred, no-round-trip operation: that outcome depends on the consumer's entity mapping, not on this library.
BR-NH-16 Legacy registration API is a compile-time error¶
Field
Value
Statement
Calls to the legacy pre-.NET Core 3.1 registration API (IServicesNhibernateExtensions, 13 methods) are compile-time errors; every member is a no-op that returns services unchanged.
Trigger
Consumer code calls any of AddUnitOfWorkAccessor, UseOracleSession, UsePostGreSqlSession, UseSqlSession, AddOracleUnitOfWork, AddSqlUnitOfWork, or AddPostGreSqlUnitOfWork.
Enforcement
[Obsolete("Due to functional changes between .net core 2.2 and .net core 3.1, this method is obsolete to avoid startup errors.", true)] on every member of IServicesNhibernateExtensions.
Outcome
Consumers are forced to migrate to AddNhibernateServices/ConfigureUnitOfWorkOptions; the checked-in README.md still documents the obsolete API and should not be treated as current.*
BR-NH-17 SystemDate comes from the database server¶
Field
Value
Statement
SystemDate is always obtained from the database server through an engine-specific query, never from the application server clock under normal operation.
Trigger
ISessionHelper.SystemDate / IRepository<T>.SystemDate is read.
Enforcement
SessionHelperBase.SystemDate runs Session.CreateSqlQuery(DateSql).UniqueResult() on every access; DateSql is set per engine by the concrete *SessionHelper subclass.
Outcome
Audit timestamps built from SystemDate reflect database server time, not application server time, as long as the query succeeds. On PostgreSQL, the returned value is date-only (midnight, no time-of-day component) — unlike Oracle's sysdate/SQL Server's GetDate(), which both return full date+time — so SystemDate on PostgreSQL is unsuitable for audit timestamps that need time-of-day precision.
BR-NH-18 SystemDate falls back to local time on sentinel values¶
Field
Value
Statement
If the database system-date query returns DateTime.MinValue or DateTime.MaxValue, SystemDate falls back to the application server's local DateTime.Now.
Trigger
The system-date query result converts to one of the two sentinel DateTime values.
Enforcement
SessionHelperBase.SystemDate compares the converted result to DateTime.MinValue/DateTime.MaxValue before returning.
Outcome
A successfully converted result equal to DateTime.MinValue/DateTime.MaxValue silently substitutes local server time rather than surfacing an error. This fallback is narrow: a failed Convert.ToDateTime or a query/connectivity exception is not caught anywhere in SystemDate and propagates to the caller unhandled — it is not masked by this fallback.
A new UnitOfWorkOptions instance is created. Under the .NET Options framework, IOptionsSnapshot<UnitOfWorkOptions>.Get(name) resolves (and re-runs all matching Configure calls for) a named options instance once per name per DI scope — it is cached and reused for the rest of that scope's lifetime, not reconstructed on every single Get(name) call within the same scope. This options-object lifecycle is separate from UnitOfWork/NHibernate session creation: UnitOfWorkFactory.CreateUnitOfWork/CreateSessionUnitOfWork always construct and return a brand-new UnitOfWork instance on every GetUnitOfWork(options) call, regardless of options caching.
Enforcement
The UnitOfWorkOptions constructor assigns each property its literal default.
Outcome
Unless a consumer overrides them, every database connection logs and pretty-prints all generated SQL (ShowSql/DriverFormatSql both true), even in production.
This library enforces no domain-specific business rules; all rules above are data-access infrastructure rules that every consuming application inherits by using the library's default behavior.
Note: Statements marked with * were inferred from code structure, naming
conventions, or contextual evidence where direct confirmation was not available
in source artifacts. Statements marked with ** could not be confirmed; treat
as unknown until verified.