Skip to content

SAIF.NetCore.NHibernate business rules

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.
SQL SQL-004
Source src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Extensions/IServicesCollectionExtensions.cs

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).
Source src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Options/ConfigureUnitOfWorkOptions.cs

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.
Source src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Options/ConfigureUnitOfWorkOptions.cs

BR-NH-04 Non-empty connection string required

Field Value
Statement A unit of work cannot be created without a non-empty connection string; the factory throws InvalidOperationException otherwise.
Trigger UnitOfWorkFactory.GetUnitOfWork(options) is called with options.ConnectionString null or empty.
Enforcement A guard clause at the top of GetUnitOfWork checks string.IsNullOrEmpty(options.ConnectionString) and throws before any session factory work begins.
Outcome Callers that pass an unresolved or unknown named options key fail fast with a clear exception rather than building a broken session.
SQL SQL-004
Source src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Factories/UnitOfWorkFactory.cs

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.
Source src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Factories/UnitOfWorkFactory.cs

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.
Source src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Factories/UnitOfWorkFactory.cs; src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Factories/Configuration/BaseConfiguration.cs

BR-NH-07 Unrecognized database type falls back to Oracle

Field Value
Statement An out-of-range DatabaseType value falls back to Oracle for both the engine configuration and the session helper, with no error raised.
Trigger options.Database holds a value outside the Oracle/Sql/PostGreSql enum range.
Enforcement The switch in CreateConfiguration and in BuildSessionHelper both use a default arm that selects the Oracle path.
Outcome Misconfigured or corrupted Database values silently produce an Oracle session rather than failing fast.
Tables DUAL
SQL SQL-001
Source src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Factories/UnitOfWorkFactory.cs

BR-NH-08 Fluent and HBM mappings both loaded

Field Value
Statement Both FluentNHibernate ClassMap<T> mappings and embedded NHibernate HBM (*.hbm.xml) mappings are loaded from every registered assembly.
Trigger BaseConfiguration.GetConfiguration() builds the FluentNHibernate configuration.
Enforcement For each assembly: FluentMappings.AddFromAssembly(a) and HbmMappings.AddFromAssembly(a) are both called.
Outcome A consuming application can mix Fluent and legacy HBM mapping styles within the same mapping assembly set.
SQL SQL-004
Source src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Factories/Configuration/BaseConfiguration.cs

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.
Source src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Factories/UnitOfWorkFactory.cs, src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Factories/Configuration/SqlDebugOutputInterceptor.cs

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.
Source src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Factories/UnitOfWorkFactory.cs, src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Options/UnitOfWorkOptions.cs

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.
SQL SQL-004
Source src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/UnitOfWork.cs, src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/SessionStrategies/StatefulSessionStrategy.cs

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.
SQL SQL-004
Source src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/UnitOfWork.cs, NHibernate 5.2.7 source: src/NHibernate/AdoNet/ConnectionManager.cs (AfterTransaction, Transaction), src/NHibernate/Transaction/AdoTransaction.cs (Commit, AfterTransactionCompletion)

BR-NH-13 Commit requires an active transaction

Field Value
Statement 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.
SQL SQL-004
Source src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/UnitOfWork.cs

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.**
Source src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/UnitOfWork.cs

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.
Source src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/SessionStrategies/StatefulSessionStrategy.cs, src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/SessionStrategies/StatelessSessionStrategy.cs

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.*
Source src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Extensions/IServicesNhibernateExtensions.cs

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.
Tables DUAL
SQL SQL-001, SQL-002, SQL-003
Source src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/SessionHelpers/SessionHelperBase.cs

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.
SQL SQL-001, SQL-002, SQL-003
Source src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/SessionHelpers/SessionHelperBase.cs

BR-NH-19 UnitOfWorkOptions default values

Field Value
Statement UnitOfWorkOptions's constructor sets non-trivial defaults: BatchSize = 20, ShowSql = true, DriverFormatSql = true, StatelessSessionStrategy = false, Name = "default", Database = Oracle.
Trigger 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.
SQL SQL-001
Source src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Options/UnitOfWorkOptions.cs

Notes

  • 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.

Sources

  • src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Extensions/IServicesCollectionExtensions.cs
  • src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Extensions/IServicesNhibernateExtensions.cs
  • src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Factories/UnitOfWorkFactory.cs
  • src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Factories/Configuration/BaseConfiguration.cs
  • src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Factories/Configuration/SqlDebugOutputInterceptor.cs
  • src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Options/ConfigureUnitOfWorkOptions.cs
  • src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/Options/UnitOfWorkOptions.cs
  • src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/UnitOfWork.cs
  • src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/SessionStrategies/StatefulSessionStrategy.cs
  • src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/SessionStrategies/StatelessSessionStrategy.cs
  • src/SAIF.NetCore.Nhibernate/SAIF.NetCore.NHibernate/SAIF.NetCore.NHibernate/SessionHelpers/SessionHelperBase.cs
  • NHibernate 5.2.7 (resolved via FluentNHibernate 3.0.1): src/NHibernate/AdoNet/ConnectionManager.cs (AfterTransaction, Transaction), src/NHibernate/Transaction/AdoTransaction.cs (Commit, AfterTransactionCompletion)