ProphetsWay.BaseDataAccess 3.0.0

Prefix Reserved
There is a newer version of this package available.
See the version list below for details.
dotnet add package ProphetsWay.BaseDataAccess --version 3.0.0
                    
NuGet\Install-Package ProphetsWay.BaseDataAccess -Version 3.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="ProphetsWay.BaseDataAccess" Version="3.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ProphetsWay.BaseDataAccess" Version="3.0.0" />
                    
Directory.Packages.props
<PackageReference Include="ProphetsWay.BaseDataAccess" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add ProphetsWay.BaseDataAccess --version 3.0.0
                    
#r "nuget: ProphetsWay.BaseDataAccess, 3.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package ProphetsWay.BaseDataAccess@3.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=ProphetsWay.BaseDataAccess&version=3.0.0
                    
Install as a Cake Addin
#tool nuget:?package=ProphetsWay.BaseDataAccess&version=3.0.0
                    
Install as a Cake Tool

ProphetsWay.BaseDataAccess

Define your data-access contracts once, keep business logic independent of storage technology, and replace the DAL without rewriting its consumers.

Build Status NuGet

Why BaseDataAccess

When domain models and persistence APIs live inside a specific DAL implementation, business logic becomes coupled to that database or framework. Replacing Entity Framework, a SQL provider, or even the storage model then becomes an application-wide change.

BaseDataAccess gives you storage-neutral entity and DAO contracts. Your business layer depends on those contracts, while each Data Access Layer (DAL) implementation satisfies them independently.

Highlights

  • Swap DAL implementations while keeping the business-facing contract stable.
  • Give each entity a strongly typed DAO surface with optional CRUD, list, and paging contracts.
  • Expose one aggregate data-access interface for dependency injection and testing.
  • Use generic CRUD dispatch when it helps, or implement IBaseDataAccess directly when reflection is not appropriate.
  • Mix identifier types across entities; generic Get<T>(object id) does not impose one key type on the whole DAL.
  • Work against a written transaction contract — one transaction per instance, no nesting, rolled back on disposal — instead of guessing what each DAL means by "commit".
  • Work against a written disposal contract. IBaseDataAccess extends IDisposable, so a DAL fits a using block and a dependency-injection scope without special handling.

Install

With the .NET CLI:

dotnet add package ProphetsWay.BaseDataAccess

With the NuGet Package Manager Console:

Install-Package ProphetsWay.BaseDataAccess

Targets: .NET Standard 2.0, .NET Framework 4.8, .NET 8.0, and .NET 9.0.

Quick Start

Create a contracts project such as YourApp.DataAccess. Put its entities, DAO interfaces, and aggregate DAL interface there so neither the business layer nor the contracts project references a storage implementation.

Illustrative — not currently present in the repo.

using ProphetsWay.BaseDataAccess;

public sealed class Customer : IBaseIdEntity<int>
{
    public int Id { get; set; }
    public string Name { get; set; }
}

public interface ICustomerDao : IBasePagedDao<Customer> { }

public interface IAppDataAccess : IBaseDataAccess, ICustomerDao { }

Your business services can now depend on IAppDataAccess. A project such as YourApp.DataAccess.MSSQL, YourApp.DataAccess.MySQL, YourApp.DataAccess.Oracle, YourApp.DataAccess.SQLite, or YourApp.DataAccess.EF supplies the implementation without leaking that technology into the contract.

IBaseDataAccess extends IDisposable, so whoever owns the instance disposes it:

Illustrative — not currently present in the repo.

using (IAppDataAccess dal = new InMemoryAppDataAccess())
{
    dal.Insert(new Customer { Name = "Acme" });

    var page = dal.GetPaged<Customer>(0, 25);
    var total = dal.GetCount<Customer>();
}

Implement one aggregate DAL shows everything InMemoryAppDataAccess has to supply, including the Dispose override that 3.0.0 made mandatory.

Core Concepts

Contracts stay above implementations

The package supplies a vocabulary for the boundary between business logic and persistence. IBaseEntity marks participating entities, the DAO interfaces describe entity-specific operations, and IBaseDataAccess describes operations shared by the complete DAL.

flowchart LR
    Business[Business logic] -->|depends on| Contract[YourApp.DataAccess contracts]
    Contract -->|uses| Package[ProphetsWay.BaseDataAccess]
    Sql[YourApp.DataAccess.MSSQL] -. implements .-> Contract
    Memory[YourApp.DataAccess.NoDB] -. implements .-> Contract
    Ef[YourApp.DataAccess.EF] -. implements .-> Contract

The implementation dependency points inward toward the contract. The contracts project must not reference a provider-specific implementation or expose types such as DbContext or SqlConnection.

DAO contracts compose by capability

IBaseDao<T> defines CRUD. IBaseGetAllDao<T> and IBasePagedDao<T> independently add retrieval capabilities, so an entity exposes only the operations it needs. Add domain-specific members to the entity DAO rather than forcing them into a universal repository abstraction.

flowchart TD
    Entity[IBaseEntity]
    Id[IBaseIdEntity of TId]
    Soft[IBaseSoftEntity]
    SoftId[IBaseSoftIdEntity of TId]
    Dao[IBaseDao of T]
    All[IBaseGetAllDao of T]
    Paged[IBasePagedDao of T]

    Id --> Entity
    Soft --> Entity
    SoftId --> Id
    SoftId --> Soft
    Dao --> Entity
    All --> Dao
    Paged --> Dao

IBaseGetAllDao<T> and IBasePagedDao<T> are siblings. Paging does not imply that loading every record is available.

Generic dispatch is optional

BaseDataAccess implements the generic members of IBaseDataAccess by locating an exact public instance overload on the derived DAL. This lets callers write dal.Get<Customer>(id) while the concrete DAL keeps strongly typed methods such as Get(Customer item).

sequenceDiagram
    participant Caller
    participant Base as BaseDataAccess
    participant DAL as Concrete DAL

    Caller->>Base: Get<Customer>(42)
    Base->>Base: Find public Get(Customer)
    Base->>Base: Validate declared return type
    Base->>Base: Create Customer and assign CustomerId or Id
    Base->>DAL: Get(customerProbe)
    DAL-->>Caller: Customer or null

Reflection is a convenience, not a requirement. You can override the virtual generic methods, or implement IBaseDataAccess directly, when you want explicit dispatch or need to avoid reflection.

The item parameter is a type selector, never data

GetAll(T item), GetPaged(T item, int, int), and GetCount(T item) take an entity parameter for exactly one reason: to give each entity's overload a distinct CLR signature. Nothing is read from it, and an implementation must never read it. When the call arrives through BaseDataAccess, the dispatcher passes a literal null — materialized as default(T) when the entity is a struct.

Dereferencing that parameter compiles cleanly and throws NullReferenceException the first time a caller uses the generic surface.

Illustrative — not currently present in the repo. Both lines are bodies on a DAL implementing IBaseGetAllDao<Customer> over a List<Customer> _customers.

//throws when the call comes through the generic dispatcher - item is null
public IList<Customer> GetAll(Customer item) => _customers.Where(c => c.Name == item.Name).ToList();

//the parameter selects the type and nothing else
public IList<Customer> GetAll(Customer item) => _customers.ToList();

IBaseDao<T>.Get(T item) is the exception. There the argument carries the identifier and is genuinely read.

Transactions are a scalpel, not an ambient system

TransactionStart(), TransactionCommit(), and TransactionRollBack() exist for one narrow job: making a bounded batch of writes atomic, so a complex set of records either all persist or none of them do. Open one, do the batch, close it. They are not an ambient transaction system and not a unit-of-work framework. The contract binds every implementation:

Rule Consequence
One transaction per DAL instance A second TransactionStart() while one is open throws InvalidOperationException. Transactions do not nest.
Scope is the instance, not the connection Two DAL instances over the same database do not share a transaction; work done through one is not enrolled in the other's.
Commit and rollback require an open transaction Calling either with nothing open throws InvalidOperationException, including on a second call.
A failed commit closes the transaction and discards its writes Do not follow a failed commit with TransactionRollBack(). Nothing is left to roll back and that call throws.
No transaction open means auto-commit A single write does not need a transaction to be durable.
Ambient transactions are left alone These members neither create nor suppress a TransactionScope; whatever your provider already does inside an enclosing scope keeps happening.
Disposal rolls back A transaction still open when the instance is disposed is rolled back, never committed. An unclosed transaction is an abandoned one.

Transaction state lives on the instance, so an instance with a transaction open must not be used from more than one thread while that transaction is open. Beyond that single consequence, this library makes no thread-safety guarantee.

Disposal is part of the contract

IBaseDataAccess extends IDisposable, because a DAL owns things that must be released — a connection, a context, possibly an open transaction. The rules:

  • Dispose is idempotent. A second call, and every call after it, is a no-op that never throws. That is deliberately the opposite of the transaction members: a repeated transaction call means the caller lost track of its own control flow and is worth surfacing, while disposing an already-disposed object is a normal thing for cleanup code to do.
  • Any member other than Dispose throws ObjectDisposedException once disposed. That type derives from InvalidOperationException, which is what the transaction members throw, so one catch (InvalidOperationException) covers both "you called this at the wrong time" cases.
  • Dispose never throws, including when a rollback it performs fails. A failed rollback during disposal is swallowed; an implementation may log it but must not propagate it. Throwing from Dispose would mask any in-flight exception inside a using block.
  • A DAL disposes what it created, and nothing else. A connection, context, or transaction handed into the DAL still belongs to the caller who supplied it. That is where double-dispose bugs live.

If you register IBaseDataAccess in a dependency-injection container, the container disposes your DAL at the end of the scope it was resolved in — it had no reason to before 3.0.0. Nothing at your call sites changes, but the lifetime of the object underneath them does. Choosing a lifetime that matches the resources your implementation holds is your decision, not this contract's.

API Reference

Entity contracts

Type Member Purpose
IBaseEntity Marker interface Identifies an entity that can participate in the DAL contracts.
IBaseIdEntity<T> T Id { get; set; } Adds a strongly typed Id property. It describes shape only — Get<T> does not key on it, resolving the identifier by name instead, so implementing it satisfies the Id fallback but changes no dispatch behavior.
IBaseSoftEntity CreatedDate, UpdatedDate, DeletedDate Carries timestamps for creation, updates, and soft deletion. It does not itself implement delete behavior.
IBaseSoftIdEntity<T> Combined contract Combines IBaseSoftEntity and IBaseIdEntity<T>.

DAO contracts

Type Member Purpose
IBaseDao<T> Get(T) Loads the entity matching the identifier carried by the argument. Use the return value; the contract does not promise the argument was populated, and both "populate the instance handed in" and "materialize a fresh one" conform.
IBaseDao<T> Insert(T) Inserts an entity. Assigning the store-generated identifier back onto it is an implementation convention the library neither performs nor verifies.
IBaseDao<T> Update(T) Updates an entity and returns the rows affected — 0 when the identifier matches no stored row. Returning 1 for a row that exists is an implementation convention, not a library guarantee.
IBaseDao<T> Delete(T) Deletes an entity and returns the rows affected, on the same terms as Update.
IBaseGetAllDao<T> GetAll(T) Retrieves all entities of T. The parameter is a type selector only and arrives as null through the generic dispatcher — never read it.
IBasePagedDao<T> GetPaged(T, int skip, int take) Retrieves a subset of entities. The parameter is a type selector only.
IBasePagedDao<T> GetCount(T) Returns the total GetPaged is paged against. It belongs to the paging capability rather than standing on its own; the parameter is a type selector only.

Aggregate DAL contracts

Type Member Purpose
IBaseDataAccess Get<T>(object id) Retrieves an entity by assigning the ID to a new probe entity.
IBaseDataAccess GetAll<T>() Dispatches to GetAll(T) without requiring the caller to create a probe.
IBaseDataAccess GetPaged<T>(int skip, int take) Dispatches to GetPaged(T, int, int).
IBaseDataAccess GetCount<T>() Dispatches to GetCount(T). The companion to GetPaged<T>, not a standalone count.
IBaseDataAccess Insert<T>(T), Update<T>(T), Delete<T>(T) Exposes generic write operations.
IBaseDataAccess TransactionStart(), TransactionCommit(), TransactionRollBack() Lets business logic coordinate multiple DAL calls in one transaction. Each throws InvalidOperationException when called at the wrong time.
IBaseDataAccess Dispose() — inherited from IDisposable Releases what the DAL created and rolls back any transaction still open. Idempotent, and never throws.
BaseDataAccess Virtual generic operations Provides the reflection-based implementation of the generic operations.
BaseDataAccess public abstract void Dispose() Declared abstract, not virtual, so a derived DAL cannot inherit an empty implementation by accident.
BaseDataAccess TransactionStart(), TransactionCommit(), TransactionRollBack() All three are abstract; the base class holds no transaction state of its own.
DataAccessConventionException Exception type Reports deterministic method, return-type, or identifier-property wiring errors.

The BaseDataAccess Convention

If your concrete DAL inherits BaseDataAccess, each generic call requires an exact public instance method. Entity parameters cannot be replaced by IBaseEntity, a base class, or another assignable type.

Generic call Required concrete method Required declared return type
Get<T>(id) Get(T) T or a subclass of T
GetAll<T>() GetAll(T) Assignable to IList<T>
GetPaged<T>(skip, take) GetPaged(T, int, int) Assignable to IList<T>
GetCount<T>() GetCount(T) int
Insert<T>(item) Insert(T) Unconstrained; the result is discarded
Update<T>(item) Update(T) int
Delete<T>(item) Delete(T) int

For Get<T>(id), the dispatcher creates T and sets {TypeName}Id first, falling back to Id. The property must have a setter, though that setter need not be public — a private set, protected set, internal set, or init is resolved and invoked exactly as a public one is. Only the complete absence of a set accessor is a failure. Other operations do not require either property.

An identifier the property cannot hold raises ArgumentException, not DataAccessConventionException — that split is deliberate, separating caller error from wiring error. Get<T>(null) throws when the identifier property is a non-nullable value type, because that property cannot hold null. A reference-type identifier such as string, or a nullable value type such as int?, accepts null normally.

Beyond the dispatch convention, BaseDataAccess declares TransactionStart, TransactionCommit, TransactionRollBack, and Dispose abstract. It holds no connection, context, or transaction state of its own, so it has nothing to implement them with. Your derived class must supply all four — including a DAL that owns nothing disposable, which still writes the empty Dispose override deliberately rather than inheriting one by default.

The dispatcher validates the method and its declared return type before invoking it. A bad Update or Delete signature therefore fails before it can write data. Exceptions thrown by the concrete DAL, the entity constructor, or the identifier setter reach the caller as their original types with their original stack traces; they are not wrapped in TargetInvocationException.

Get<T>, GetAll<T>, and GetPaged<T> may return null, and a null is forwarded to the caller untouched rather than treated as a convention failure. Collection return values should be treated as read-only because arrays satisfy IList<T> but do not support mutation.

One consequence is worth knowing before you model an entity as a struct: a value-type entity cannot report "not found" as null. The new() constraint admits structs, but the derived Get must declare a return type assignable to T, which for a value type admits only T itself. A DAL keyed on a value-type entity must signal a miss another way — a sentinel value, a member outside IBaseDataAccess, or modeling the entity as a reference type.

Common Scenarios

Compose one business-facing DAL contract

The companion ProphetsWay.Example project uses an interface of interfaces. This is real code from that repository:

using ProphetsWay.BaseDataAccess;
using ProphetsWay.Example.DataAccess.IDaos;

namespace ProphetsWay.Example.DataAccess
{
    public interface IExampleDataAccess : IBaseDataAccess, ICompanyDao, IJobDao,
        IUserDao, ITransactionDao, IResourceDao
    {
    }
}

Consumers depend on IExampleDataAccess, not on its NoDB or Entity Framework implementation.

Add entity-specific operations

DAO interfaces can extend a base capability and add domain-specific queries. The Example project defines:

using ProphetsWay.BaseDataAccess;
using ProphetsWay.Example.DataAccess.Entities;

namespace ProphetsWay.Example.DataAccess.IDaos
{
    public interface ICompanyDao : IBasePagedDao<Company>
    {
        Company GetCustomCompanyFunction(int id);
    }
}

Implement one aggregate DAL

A concrete DAL supplies its entity operations plus the four members BaseDataAccess declares abstract: the three transaction members and Dispose. Dispose is abstract rather than virtual on purpose — an empty implementation should be a decision you made, not one you inherited without reading.

Illustrative — not currently present in the repo.

using System;
using System.Collections.Generic;
using System.Linq;
using ProphetsWay.BaseDataAccess;

public sealed class InMemoryAppDataAccess : BaseDataAccess, IAppDataAccess
{
    private readonly List<Customer> _customers = new List<Customer>();
    private List<Customer> _snapshot;
    private bool _disposed;

    public Customer Get(Customer item)
    {
        ThrowIfDisposed();
        return _customers.FirstOrDefault(c => c.Id == item.Id);
    }

    public void Insert(Customer item)
    {
        ThrowIfDisposed();
        item.Id = _customers.Count + 1;
        _customers.Add(item);
    }

    public int Update(Customer item)
    {
        ThrowIfDisposed();

        var stored = _customers.FirstOrDefault(c => c.Id == item.Id);
        if (stored == null)
            return 0;

        stored.Name = item.Name;
        return 1;
    }

    public int Delete(Customer item)
    {
        ThrowIfDisposed();
        return _customers.RemoveAll(c => c.Id == item.Id);
    }

    //item is a type selector - it arrives null through the dispatcher and is never read
    public IList<Customer> GetPaged(Customer item, int skip, int take)
    {
        ThrowIfDisposed();
        return _customers.Skip(skip).Take(take).ToList();
    }

    public int GetCount(Customer item)
    {
        ThrowIfDisposed();
        return _customers.Count;
    }

    public override void TransactionStart()
    {
        ThrowIfDisposed();

        if (_snapshot != null)
            throw new InvalidOperationException("A transaction is already open on this instance.");

        _snapshot = new List<Customer>(_customers);
    }

    public override void TransactionCommit()
    {
        ThrowIfDisposed();

        if (_snapshot == null)
            throw new InvalidOperationException("No transaction is open on this instance.");

        _snapshot = null;
    }

    public override void TransactionRollBack()
    {
        ThrowIfDisposed();

        if (_snapshot == null)
            throw new InvalidOperationException("No transaction is open on this instance.");

        _customers.Clear();
        _customers.AddRange(_snapshot);
        _snapshot = null;
    }

    public override void Dispose()
    {
        if (_disposed)
            return;

        if (_snapshot != null)
        {
            try
            {
                TransactionRollBack();
            }
            catch (Exception)
            {
                //a rollback that fails during disposal is swallowed; Dispose is forbidden to throw
            }
        }

        _disposed = true;
    }

    private void ThrowIfDisposed()
    {
        if (_disposed)
            throw new ObjectDisposedException(typeof(InMemoryAppDataAccess).Name);
    }
}

A DAL that owns nothing disposable still writes the override — it is simply empty:

public override void Dispose() { }

Wrap a batch of writes in one transaction

The in-repo suite pins this against ConformingDataAccess, a hand-written implementation of IBaseDataAccess that obeys the contract. Writes made inside a transaction are invisible until it commits:

var dal = new ConformingDataAccess();
dal.TransactionStart();
dal.Insert(new Company { Name = "Acme" });
dal.GetCount<Company>().ShouldBe(0);

dal.TransactionCommit();

dal.GetCount<Company>().ShouldBe(1);
dal.TransactionIsOpen.ShouldBeFalse();

A commit that fails leaves no transaction open and discards the writes made inside it, so recovery code must not roll back afterwards:

//there is no transaction left to roll back - this catch block throws its own exception
try { dal.TransactionCommit(); }
catch (Exception) { dal.TransactionRollBack(); }

Call through the generic surface

The in-repo test suite verifies this call shape against a well-formed concrete DAL:

var dal = new WellFormedDataAccess();
dal.GetResult = new Company { CompanyId = 99, Name = "Returned" };

var result = dal.Get<Company>(42);

The dispatcher passes a Company probe whose CompanyId is 42 to the concrete Get(Company) overload and returns that overload's result unchanged.

Supply the type argument explicitly. Without it, a call such as dal.Get(company) binds to the derived non-generic Get(Company) overload and the reflection path is never exercised.

Architecture & Design Decisions

Why methods accept an entity parameter

Every entity-specific DAO uses the same operation names. Passing T gives each overload a unique CLR signature, allowing one aggregate interface and implementation to expose Get(Company), Get(User), and other entity operations together. For GetAll, GetCount, and GetPaged, the parameter is a type discriminator only — the generic dispatcher passes null, and an implementation must never read it.

If this convention does not fit your API, define explicit methods such as GetCustomer(int customerId) in your own DAO interfaces and implement IBaseDataAccess without inheriting BaseDataAccess.

Why contracts and implementations are separate projects

A typical solution uses a base contracts project and one project per replaceable implementation:

YourApp.DataAccess
|- Entities/
|- IDaos/
`- IYourAppDataAccess.cs

YourApp.DataAccess.MSSQL
|- Daos/
`- YourAppDataAccess.cs

The contracts project owns the models used by the rest of the application. Every implementation adapts its storage technology to those models, rather than making business logic consume provider-generated entities. Focused concrete DAO classes can remain internal; only the aggregate DAL needs to be available to consumers.

Reflection trade-off

The generic dispatcher removes repetitive type switches and probe construction from callers, but reflection adds runtime convention checks and overhead. The convention is strict and deterministic, and its failures use DataAccessConventionException with the offending type and signature. Applications with tighter performance requirements can override the virtual members or avoid the abstract base class entirely.

Soft-delete contracts describe data, not policy

IBaseSoftEntity standardizes lifecycle timestamps. It does not automatically filter deleted rows or turn Delete into an update; each DAL implementation owns that behavior. Nothing in this library reads CreatedDate, UpdatedDate, or DeletedDate.

Why Dispose is abstract rather than virtual

BaseDataAccess dispatches by reflection and holds no connection, context, or transaction state of its own, so it has nothing to release. It could have supplied an empty virtual Dispose and spared every implementer a line of code. It does not, because the resources a DAL owns and the transaction it may be holding open are facts only the implementer knows. Making the member abstract forces that judgment to be made once, in the open, instead of inherited silently.

Why GetCount lives on the paging interface

Paging is one capability expressed as two members. A pager cannot render a page count, a last-page control, or any bound on how far forward the user may move without knowing the total it is paging over — so a total is part of what paging is, not a feature bolted alongside it.

Counting as an independent capability was considered and rejected. A consumer who wants a bare count wants it for a reason — active users, unpaid invoices, records touched since a date — and that reason almost always carries filters a generic GetCount<T>() cannot express. The supported answer is a custom count method on your own DAO interface, carrying the filters that made it worth asking for.

What was considered and left out

Nested transactions and savepoints, a general thread-safety contract, async members, IAsyncDisposable, and a standalone count capability were all weighed and deliberately not built. Each decision is recorded with its reasoning in docs/feature-requests.md, along with a proposal for a published conformance kit that would let a DAL implementation prove it honors the contract. Read that file before opening a feature request — it tells you what has already been weighed.

Building & Testing Locally

git clone https://github.com/ProphetManX/ProphetsWay.BaseDataAccess
cd ProphetsWay.BaseDataAccess
dotnet restore
dotnet build
dotnet test

The ProphetsWay.BaseDataAccess.Tests project contains 115 xUnit tests. They cover generic dispatch, method lookup, return types, identifier resolution and assignment, identifier rejection, null arguments and null results, struct entities, shadowed methods, and exception propagation — plus 35 tests over ConformingDataAccess, a hand-written implementation of IBaseDataAccess that pins the disposal and transaction contracts. Those 35 prove a correct implementation is expressible and fix the contract's meaning; they say nothing about any other implementation, which is the gap docs/feature-requests.md proposes a conformance kit to close.

The companion ProphetsWay.Example repository demonstrates the same contracts with a NoDB implementation; the EFTools repository supplies an Entity Framework implementation.

Contributing

Keep public contracts storage-neutral and preserve the exact reflection convention when changing BaseDataAccess. Add or update xUnit tests for behavioral changes, use Shouldly assertions, and run the full test suite before submitting a change.

Versioning

This project follows Semantic Versioning. Available releases are listed in the repository tags.

Authors

Created by G. Gordon Nasseri. See the repository's contributors for additional participants.

Changelog

See CHANGELOG.md. Version 3.0.0 makes IBaseDataAccess extend IDisposable and specifies the disposal and transaction contracts in full; it also introduced strict convention validation, unwrapped implementation exceptions, consolidated target frameworks, and the in-repo test suite. Read the 3.0.0 entry before upgrading — the unwrapped exceptions and the new Dispose obligation both break existing code.

License

MIT License - see LICENSE.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 is compatible.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • .NETFramework 4.8

    • No dependencies.
  • .NETStandard 2.0

    • No dependencies.
  • net8.0

    • No dependencies.
  • net9.0

    • No dependencies.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on ProphetsWay.BaseDataAccess:

Package Downloads
ProphetsWay.EFTools

A small library that is useful when utilizing EntityFramework for a Data Access Layer (DAL) while adhering to Business Layer to DAL decoupling. This uses the paradigm explained in https://github.com/ProphetManX/ProphetsWay.BaseDataAccess. See the README for more information. For more information on this project, please go to https://github.com/ProphetManX/ProphetsWay.EFTools.

ProphetsWay.iBatisTools

A small library that is useful when utilizing iBatisNet for a Data Access Layer (DAL). Go to https://github.com/ProphetManX/ProphetsWay.iBatisTools for more information.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
3.2.0 139 8/23/2026
3.2.0-506.Beta 38 8/23/2026
3.2.0-506.Alpha 50 8/23/2026
3.2.0-504.Alpha 41 8/23/2026
3.1.0 116 8/14/2026
3.1.0-495.Beta 47 8/14/2026
3.1.0-495.Alpha 45 8/14/2026
3.0.0 100 8/12/2026
3.0.0-485.Beta 53 8/12/2026
3.0.0-485.Alpha 44 8/12/2026
3.0.0-481.Alpha 44 8/11/2026
3.0.0-480.Beta 51 8/9/2026
3.0.0-480.Alpha 52 8/9/2026
3.0.0-478.Alpha 46 8/9/2026
2.5.0 361 5/2/2025
2.5.0-469.Beta 161 5/2/2025
2.5.0-469.Alpha 166 5/2/2025
2.4.0 299 5/1/2025
2.4.0-463.Beta 166 5/1/2025
2.4.0-463.Alpha 152 5/1/2025
Loading failed

# v3.0.0
### Exceptions from your DAL now reach you unwrapped — read this before upgrading
This is the one change in this release that breaks quietly. Every one of the generic calls on ```BaseDataAccess```
reaches your derived method through Reflection, and Reflection used to wrap anything that method threw in a
```TargetInvocationException```. That wrapper is gone. Whatever your ```Get```, ```GetAll```, ```GetPaged```,
```GetCount```, ```Insert```, ```Update``` or ```Delete``` throws now arrives at the caller as its own type with its
original stack trace intact, and the same is now true of an exception thrown by an entity's parameterless constructor
or by its identifier property setter.

If you wrote a handler that reaches through the wrapper, it will no longer match and your handling will simply stop
running — there is no compiler error and no warning to tell you.

```c#
//no longer catches anything - the handler is now dead code
try { dal.Update(company); }
catch (TargetInvocationException ex) { Log(ex.InnerException); }

//catch what your DAL actually throws
try { dal.Update(company); }
catch (SqlException ex) { Log(ex); }
```

Search your solution for ```TargetInvocationException``` before you upgrade. If a call site wrapped one of these
methods, that is the code that has to change.

### Your Data Access Layer must now implement ```Dispose```
```IBaseDataAccess``` extends ```IDisposable```, so every implementation has to supply ```Dispose```. If you inherit
```BaseDataAccess``` it is declared ```public abstract void Dispose();```, which means your class will not compile
until you override it. That includes a Data Access Layer holding nothing disposable at all — you still write the
override, empty. Making it abstract rather than virtual is the point: an empty ```Dispose``` should be a decision you
made, not a default you inherited without reading.

```c#
//a DAL that owns no connection, context or transaction still writes this
public override void Dispose() { }
```

The quieter consequence is at your composition root. If you register ```IBaseDataAccess``` in a dependency-injection
container, the container now disposes your Data Access Layer at the end of the scope it was resolved in — it has no
reason to have done so before. Nothing at your call sites changes, but the lifetime of the object underneath them
does, and a Data Access Layer that was quietly outliving its scope will stop doing so. Registering it as a singleton
is one way to control that; which lifetime is right is your call, but it is now a call you have to make.

### The disposal contract is now specified
```Dispose``` is idempotent — a second call, and every call after it, is a no-op that must never throw. Read that
against the transaction members, which set the opposite precedent deliberately: calling ```TransactionCommit``` or
```TransactionRollBack``` twice throws, because a repeated transaction call means the caller has lost track of its own
control flow, while disposing an already-disposed object is a normal thing for cleanup code to do.

Calling any member *other than* ```Dispose``` on a disposed instance throws ```ObjectDisposedException```. That type
derives from ```InvalidOperationException```, which is what the transaction members throw, and the overlap is
intentional — one ```catch (InvalidOperationException)``` covers both "you used this wrong" cases rather than making
you write two handlers for the same class of mistake.

```Dispose``` itself never throws, including when a rollback it performs fails. Throwing from ```Dispose``` masks
whatever exception was already in flight inside a ```using``` block, turning a diagnosable failure into a misleading
one, and an abandoned transaction is rolled back by the database anyway once the connection drops. A transaction still
open at disposal is **rolled back, never committed** — an unclosed transaction is an abandoned one, and abandoned work
is not work you meant to keep.

Finally: your Data Access Layer disposes what it created, and nothing else. If a caller hands it a connection, a
context or a transaction, disposing the Data Access Layer must not dispose that resource — the caller still owns it.
That is where double-dispose bugs live, so it is now written down rather than assumed.

### The transaction contract is now specified
One transaction per Data Access Layer instance. A second ```TransactionStart``` while one is already open throws
```InvalidOperationException```, and so does a commit or rollback with none open. Transactions do not nest, and that
is a decision rather than an omission — these three members are a scalpel for making a bounded batch of writes atomic,
not an ambient transaction system and not a unit-of-work framework.

Scope is the **instance**, not the connection. Two instances pointed at the same database do not share a transaction,
and work done through one is not enrolled in the other's.

A failed commit leaves no transaction open **and discards the writes made inside it**. Once ```TransactionCommit```
returns or throws, the instance has nothing open, so a failed commit must not be followed by ```TransactionRollBack```
— there is nothing left to roll back and that call throws.

```c#
//there is no transaction left to roll back - this catch block throws its own exception
try { dal.TransactionCommit(); }
catch (Exception) { dal.TransactionRollBack(); }
```

Outside a transaction every call auto-commits on its own, so you do not need one to make a single write durable. And
ambient transactions are left alone — these members neither create a ```TransactionScope``` nor suppress one, so
whatever your provider already does inside an enclosing scope keeps happening unchanged.

### Documentation corrections — the first one could have cost you a debugging session
```GetAll```, ```GetPaged``` and ```GetCount``` documented their ```item``` parameter as needing to be "an instance of
itself". That was simply false. The generic dispatcher invokes your derived method with a literal ```null``` —
```default(T)``` when the entity is a value type — so the parameter is a **type selector** and must never be read. An
implementer who trusted the old documentation and dereferenced it compiled clean and threw ```NullReferenceException```
the first time the call arrived through the dispatcher, with nothing in the build to warn them.

```c#
//throws when the call comes through the generic dispatcher - item is null
public IList<Company> GetAll(Company item) => _ctx.Companies.Where(c => c.Region == item.Region).ToList();

//the parameter selects the type and nothing else
public IList<Company> GetAll(Company item) => _ctx.Companies.ToList();
```

```IBaseDao<T>.Get``` promised to "return the passed Object", which constrained instance identity for no reason.
Whether an implementation populates the instance it was handed or materializes a fresh one is now explicitly
unspecified and both conform — use the return value, and do not assume the argument was mutated.

```Insert``` assigning the store-generated identifier back onto the entity, and ```Update```/```Delete``` returning 1,
are now described as conventions an implementation is expected to honor rather than guarantees the library makes or
verifies. ```0``` from an update or delete is correct for an identifier matching no row.

```GetCount``` is documented as a required component of paging rather than a standalone feature — a pager cannot render
without knowing the total it is paging over. If you want a count and do not need paging, declare your own count method
on your own Dao interface; that is the supported answer, not a gap.

```IBaseEntity```, ```IBaseIdEntity<T>```, ```IBaseSoftEntity``` and ```IBaseSoftIdEntity<T>``` had no documentation at
all and now do. Two points on them are worth reading even if you have been using them for years. ```Get<T>``` does
**not** key on ```IBaseIdEntity<T>``` — the identifier is resolved by name, ```{TypeName}Id``` first and ```Id``` as
the fallback, so implementing the interface satisfies the fallback but an entity also exposing ```{TypeName}Id``` has
that one used instead. And the library reads none of ```CreatedDate```, ```UpdatedDate``` or ```DeletedDate```; soft
delete is entirely your implementation's behavior, and these interfaces only mark which entities take part in it.

### The method convention is now enforced strictly, and enforced before anything is written
The remaining breaking changes all fail loudly on the first call, so they will find you rather than the other way
around.

Parameter matching is now exact. A convention method declared with a base class or interface parameter —
```Insert(IBaseEntity item)``` standing in for every entity type — used to be matched by the Reflection binder and
will no longer be found. Declare the method once per entity type, with that entity type as the parameter.

Declared return types are validated *before* the method is invoked. ```GetAll``` and ```GetPaged``` must declare a
type assignable to ```IList<T>```, ```Get``` must declare ```T``` or a subclass of it, and ```GetCount```,
```Update``` and ```Delete``` must declare ```int```. ```Insert``` remains unconstrained and may return anything,
including ```void```. Previously a mis-declared ```Update``` or ```Delete``` ran to completion, wrote to the
database, and only then failed casting its result; that no longer happens.

```static``` convention methods are no longer found. The lookup has always been documented as targeting the public
surface of your DAL, but ```static``` methods were incidentally reachable; they are not any more. Make the method a
public instance method.

The obsolete ```IBaseDataAccess<TIdType>``` and ```BaseDataAccess<TIdType>```, deprecated back in v2.1.0, have been
removed. The fix is the one the obsolete warning has been suggesting for four minor versions — drop the generic
argument and use ```IBaseDataAccess``` / ```BaseDataAccess```.

### Target frameworks consolidated
Now targeting ```netstandard2.0;net48;net8.0;net9.0```, replacing ```net461;net471;net48;net50;net60;net70;net80;net90```.
No consumer is stranded: ```net461``` and ```net471``` resolve against ```netstandard2.0```, as do .NET 5, 6 and 7,
all three of which are past end of support.

### Fixed
A struct entity silently received a default identifier from ```Get<T>```. The probe entity was boxed at the moment
the identifier was assigned, so the mutation landed on a copy that was then discarded and your ```Get``` method
received an entity with an unset key — no exception, just the wrong row or no row. Struct entities now receive the
identifier they were asked for.

```Get<T>(null)``` answered "not found" instead of rejecting the call. A wrong-typed identifier has always thrown
```ArgumentException``` — that is a caller mistake rather than a wiring error, which is why it is deliberately not a
```DataAccessConventionException``` — but ```null``` escaped the rule, because the reflection layer converts it to
```default``` for a non-nullable value type instead of refusing it. An entity keyed on ```int``` therefore probed for
identifier ```0``` and handed back whatever that found, in practice ```null```. Your bug came back as a plausible
answer, with no exception and nothing in the build to catch it. ```Get<T>``` now throws ```ArgumentException```
naming the property, its type and the entity type.

The test is on the **identifier property's type**, not on ```null``` itself. A reference type such as ```string```,
or a nullable value type such as ```int?```, can hold ```null```, and for those ```null``` still reaches your
```Get``` unchanged. Only a non-nullable value type rejects it.

```c#
//identifier property is int - used to return null, now throws ArgumentException
dal.Get<Company>(null);

//identifier property is string or int? - unchanged, null reaches your Get
dal.Get<Account>(null);
```

If you were passing ```null``` and relying on ```null``` coming back, that call now throws — behaviorally breaking,
though it is a narrow usage and almost certainly an accidental one, since the old behavior was concealing the mistake
rather than offering a feature. None of this had any test coverage before now — the suite asserted on
```ArgumentException``` nowhere at all, which is how it survived — and closing it took the suite from 111 tests to 115.

```GetAll<T>``` and ```GetPaged<T>``` returned ```null``` when the derived method declared a return type that was not
an ```IList<T>```, because the result was coerced with ```as```. A wrong return type is now reported as the wiring
error it is.

An identifier property with no set accessor produced a raw ```ArgumentException``` from the Reflection layer. It is
now reported as a convention error naming the entity and the property.

A convention method hidden with ```new``` bound unpredictably, because the order Reflection returns same-named
methods in is unspecified. The hierarchy is now walked one level at a time, most derived first, so the method
selected is the one a compile-time call against the same type would bind to.

### Added
```DataAccessConventionException``` replaces the generic ```Exception``` previously thrown for wiring errors, so
these can be caught and filtered distinctly from data errors. It carries the full specification of the convention in
its own documentation — the method name and signature looked for, the visibility required, the return type each one
must declare, and how the identifier property is resolved for ```Get```. If you are writing a class that inherits
```BaseDataAccess```, read that type first.

Its messages render types the way you wrote them, so a signature reads as ```(Company, int, int)``` rather than
```(Company, Int32, Int32)```, and a return type as ```IList<Company>``` rather than in namespace-qualified
backtick-arity form.

```docs/feature-requests.md``` records the decisions this release deliberately did not make, and why — a published
conformance kit for verifying an implementation against these contracts, nested transactions and savepoints, a general
thread-safety contract, async members and ```IAsyncDisposable```, a standalone count capability that was considered and
**rejected**, and splitting the transaction members onto their own interface. If something here looks like an
oversight, check there first; the reasoning is written down so you can judge whether the tradeoff still holds and raise
a feature request when it stops holding.

```ProphetsWay.BaseDataAccess.Tests``` was added — the first automated coverage this library has had, 115 tests
pinning the convention, the dispatch behavior, the disposal and transaction contracts, and every fix listed above.

### The XML documentation now ships with the package
Everything above is written on the interfaces, and until now none of it reached you if you installed the package
rather than read the repository. ```GenerateDocumentationFile``` had never been set here, so no XML documentation file
was produced and nothing was packed into the nupkg — not even member summaries. The file is now emitted for all four
target frameworks and the SDK packs it beside each assembly, so your IDE has something to read. ```IBaseIdEntity<T>.Id```
picked up the summary it was missing on the way, which leaves the public surface documented in full, and the dispatched
members on ```IBaseDataAccess``` now name ```DataAccessConventionException``` and the return type each requires instead
of leaving you to find that elsewhere.

One caveat, and it belongs to the tooling rather than the package. ```<summary>```, ```<param>```, ```<typeparam>```
and ```<returns>``` surface reliably. ```<remarks>``` does not, and ```<remarks>``` is where the transaction, disposal,
threading and convention contracts actually live — Rider's quick documentation and VS Code's hover show it, while
Visual Studio's tooltip historically has not. If a tooltip stops short of the contract, it was not left out; the source
and this changelog remain the complete account.

### A note on visibility
The convention has always required a public instance method. That has not changed in this release; it is now stated
in the documentation and covered by tests rather than left to be discovered.


#2.5.0
### Added another interface to identify a base "Soft" entity without an ID property
Added an interface that identifies an entity as "Soft" but doesn't have a specific "Id" property, this is meant to be used in
conjunction with many-to-many tables, or a table that has a compound key and neither is a basic "Id" property that should be keyed off of.