Architecture

ArturRios.Data is a family of small, focused NuGet packages. This page shows how they fit together, the key types in each, and the design principles they…

ArturRios.Data is a family of small, focused NuGet packages. This page shows how they fit together, the key types in each, and the design principles they share.

Package dependencies

flowchart TB
    Output["ArturRios.Output<br/><i>DataOutput / ProcessOutput envelopes</i>"]

    subgraph Relational["Relational stack — EF Core"]
        direction TB
        Core["ArturRios.Data.Relational.Core<br/><i>interfaces, EfRepository, EfUnitOfWork,<br/>BaseDbContext, IDatabaseProvider seam</i>"]
        Sqlite["ArturRios.Data.Sqlite<br/><i>AddSqliteProvider()</i>"]
        Postgres["ArturRios.Data.PostgreSql<br/><i>AddPostgreSqlProvider()</i>"]
        MySql["ArturRios.Data.MySql"]
        Dapper["ArturRios.Data.Dapper<br/><i>read-only raw SQL</i>"]
    end

    subgraph NoSQL["NoSQL stores — standalone"]
        direction TB
        Mongo["ArturRios.Data.MongoDb<br/><i>document repository + transactions</i>"]
        Dynamo["ArturRios.Data.DynamoDb<br/><i>async repository over IDynamoDBContext</i>"]
    end

    subgraph Export["File export — standalone"]
        direction TB
        ExportCore["ArturRios.Data.Export<br/><i>exporter factory, column map,<br/>CSV / JSON / TXT / MessagePack</i>"]
        ExportExcel["ArturRios.Data.Export.Excel<br/><i>.xlsx add-on</i>"]
    end

    Sqlite --> Core
    Postgres --> Core
    MySql --> Core
    Dapper --> Core
    Core --> Output
    Mongo --> Output
    Dynamo --> Output
    ExportExcel --> ExportCore
    ExportCore --> Output

    EF["Microsoft.EntityFrameworkCore"]:::ext
    EfProviders["Npgsql / Microting (Pomelo fork) / Sqlite EF provider"]:::ext
    DapperLib["Dapper"]:::ext
    MongoLib["MongoDB.Driver"]:::ext
    Aws["AWSSDK.DynamoDBv2"]:::ext
    MsgPack["MessagePack"]:::ext
    ClosedXml["ClosedXML"]:::ext
    Core --> EF
    Sqlite --> EfProviders
    Postgres --> EfProviders
    MySql --> EfProviders
    Dapper --> DapperLib
    Mongo --> MongoLib
    Dynamo --> Aws
    ExportCore --> MsgPack
    ExportExcel --> ClosedXml

    classDef ext fill:#8882,stroke-dasharray:3 3;

The families are deliberately separate. The relational providers and the Dapper read path build on ArturRios.Data.Relational.Core (EF Core). The NoSQL packages do not depend on the relational core — pulling EF Core into a MongoDB or DynamoDB app would be wasteful — so they depend only on ArturRios.Output and their native driver. MongoDB and DynamoDB are also separate from each other: their data models diverge too much (composite keys and a key/scan access model in DynamoDB vs. documents with LINQ/predicate queries in MongoDB) to share one interface without becoming leaky. The export packages are independent of all of it — they take any IEnumerable<T>, so they need no store at all.

Excel is split out for the same reason, one level down: ClosedXML is a heavy dependency, so it lives in an add-on that apps opt into. The core keeps no compile-time reference to it — the add-on registers a marker type that the exporter factory resolves at runtime.

The result envelope

Every backend returns the same envelope types from ArturRios.Output. A ProcessOutput carries success state, error messages, and info messages; DataOutput<T> adds a typed payload.

classDiagram
    class ProcessOutput {
        +bool Success
        +List~string~ Messages
        +List~string~ Errors
        +WithError(string) ProcessOutput
    }
    class DataOutput~T~ {
        +T Data
        +WithData(T) DataOutput~T~
    }
    ProcessOutput <|-- DataOutput

Relational model

The relational core exposes four repository interfaces (a read-only tier and a full read/write tier, each in a sync and an async flavour), all constrained to T : Entity. EfRepository<T> implements all four; EfUnitOfWork implements both unit-of-work interfaces. Consumers derive their entities from Entity (or VersionedEntity for optimistic concurrency) and their DbContext from BaseDbContext.

classDiagram
    class Entity { +long Id }
    class VersionedEntity { +Guid ConcurrencyStamp }
    Entity <|-- VersionedEntity

    class IReadOnlyRepository~T~ {
        +Query() IQueryable~T~
        +GetAll() DataOutput
        +GetById(long) DataOutput
    }
    class IRepository~T~ {
        +Create(T) DataOutput
        +CreateRange(items) DataOutput
        +Update(T) DataOutput
        +UpdateRange(items) DataOutput
        +Delete(T) DataOutput
        +DeleteRange(ids) DataOutput
    }
    class IAsyncReadOnlyRepository~T~
    class IAsyncRepository~T~
    class EfRepository~T~

    IReadOnlyRepository <|-- IRepository
    IAsyncReadOnlyRepository <|-- IAsyncRepository
    IRepository <|.. EfRepository
    IAsyncRepository <|.. EfRepository

    class IUnitOfWork {
        +ExecuteInTransaction(work) ProcessOutput
    }
    class IAsyncUnitOfWork {
        +ExecuteInTransactionAsync(work) Task
    }
    class EfUnitOfWork
    IUnitOfWork <|.. EfUnitOfWork
    IAsyncUnitOfWork <|.. EfUnitOfWork

The provider seam

The core never references a specific EF provider. Each provider package implements IDatabaseProvider and registers it as a singleton, exposing which DatabaseType it handles; AddDataConfigFromSettings<TContext> (or AddDataConfigFromEnvironment<TContext>) reads the configured DatabaseType and picks the matching provider out of the registered set to configure the DbContext. This is why you call both AddXProvider() and AddDataConfigFromSettings<TContext>() / AddDataConfigFromEnvironment<TContext>().

Registration validates this eagerly: if it can prove no registered provider matches the configured DatabaseType, it throws a DataAccessException naming the missing package rather than failing on the first query.

classDiagram
    class IDatabaseProvider {
        +DatabaseType Type
        +Configure(builder, connectionString)
    }
    class SqliteProvider
    class PostgreSqlProvider
    class MySqlProvider
    IDatabaseProvider <|.. SqliteProvider
    IDatabaseProvider <|.. PostgreSqlProvider
    IDatabaseProvider <|.. MySqlProvider

MongoDB model

MongoDB uses a distinct interface family (IDocumentRepository<T> / IAsyncDocumentRepository<T> plus read-only tiers) with string/ObjectId identity. MongoDocumentRepository<T> implements them over a MongoContext that carries the ambient session used by MongoUnitOfWork transactions.

classDiagram
    class Document { +string Id }
    class VersionedDocument { +long Version }
    Document <|-- VersionedDocument

    class IDocumentRepository~T~
    class IAsyncDocumentRepository~T~
    class MongoDocumentRepository~T~
    IDocumentRepository <|.. MongoDocumentRepository
    IAsyncDocumentRepository <|.. MongoDocumentRepository

    class IMongoUnitOfWork
    class IAsyncMongoUnitOfWork
    class MongoUnitOfWork
    IMongoUnitOfWork <|.. MongoUnitOfWork
    IAsyncMongoUnitOfWork <|.. MongoUnitOfWork

DynamoDB model

DynamoDB has no shared base class — items are plain POCOs annotated with AWS attributes. The single async repository interface maps to DynamoDB’s real access model (key-based load, partition-key query, scan, batch).

classDiagram
    class IAsyncDynamoRepository~T~ {
        +SaveAsync(T) DataOutput
        +LoadAsync(hashKey) DataOutput
        +LoadAsync(hashKey, rangeKey) DataOutput
        +QueryAsync(hashKey) DataOutput
        +ScanAsync(conditions) DataOutput
        +SaveManyAsync(items) DataOutput
        +LoadManyAsync(hashKeys) DataOutput
    }
    class DynamoRepository~T~
    IAsyncDynamoRepository <|.. DynamoRepository

Export model

Export has no store and no entity base class — it takes any IEnumerable<T>. IExporter<T> is the one contract; ExporterBase<T> centralizes the null-guarding, envelope conversion, and stream lifetime, so a concrete exporter only implements the format-specific write. IExporterFactory maps an ExportFormat to the right exporter out of the container.

classDiagram
    class IExporter~T~ {
        +WriteAsync(data, stream) ProcessOutput
        +WriteToFileAsync(data, path) ProcessOutput
    }
    class ExporterBase~T~ {
        #WriteCoreAsync(data, stream, ct)
    }
    IExporter <|.. ExporterBase

    class CsvExporter~T~
    class JsonExporter~T~
    class TxtExporter~T~
    class MessagePackExporter~T~
    class ExcelExporter~T~
    ExporterBase <|-- CsvExporter
    ExporterBase <|-- JsonExporter
    ExporterBase <|-- TxtExporter
    ExporterBase <|-- MessagePackExporter
    ExporterBase <|-- ExcelExporter

    class IExporterFactory {
        +Resolve(format) IExporter~T~
    }
    class ExporterFactory
    IExporterFactory <|.. ExporterFactory
    ExporterFactory ..> IExporter : resolves

The columnar formats (CSV, Excel) share one ColumnMap, which compiles and caches a per-type column plan from the record’s public properties, honouring [ExportColumn] and [ExportIgnore].

Design principles

  • Modular packaging. One package per backend; install only what you use. NoSQL packages don’t drag in EF Core.
  • Envelopes, not exceptions. Every public repository/unit-of-work method catches infrastructure exceptions and returns them as DataOutput/ProcessOutput errors. Optimistic-concurrency conflicts become a friendly “concurrency conflict” error. The one intentional exception is OperationCanceledException, which propagates so cooperative cancellation stays idiomatic.
  • Opt-in optimistic concurrency. Derive from VersionedEntity / VersionedDocument, or add [DynamoDBVersion], to get conditional writes; without it, writes are last-writer-wins.
  • Transactions where the engine supports them. Relational and MongoDB expose a delegate-based unit of work; the Dapper read path enlists in the relational transaction. (DynamoDB transactions are a planned addition.)
  • Consistent naming. AddDataConfigFromSettings / AddMongoData / AddDynamoData / AddExport for DI; DataOutput<T> / ProcessOutput everywhere; Async suffix + CancellationToken on async members.

See the Relational, MongoDB, DynamoDB, and Export guides for full usage.

Asynchronous conventions

Every await inside these packages calls .ConfigureAwait(false). A library that captures the caller’s synchronisation context deadlocks anyone who blocks on the returned task, and forces a context hop on every resumption for no benefit — a library has no UI thread to get back to.

Where a value is bound by await using, the value is bound first and the scope declared separately:

var tx = await context.Database.BeginTransactionAsync(ct).ConfigureAwait(false);

await using var txScope = tx.ConfigureAwait(false);

Writing await using var tx = (…).ConfigureAwait(false) would make tx a ConfiguredAsyncDisposable, which is not the transaction the surrounding code needs. Splitting it keeps the variable’s own type and still configures the disposal await.

Testing

The test suite is xUnit, and every test is named with the Given / When / Then pattern. Every test class carries a Category trait, so the two kinds can be run — and reported — separately:

dotnet test src/ArturRios.Data.sln --filter "Category=Unit"
dotnet test src/ArturRios.Data.sln --filter "Category=Functional"

Unit tests exercise the code in isolation against test doubles: contracts, options, dependency-injection registration, the column map and the exporters over in-memory streams. Functional tests run against real stores — SQLite for the relational and Dapper paths, an ephemeral MongoDB replica set for the document repository and its transactions, and DynamoDB Local for the item repository. CI runs the two as separate jobs, and both must pass before a pull request can be merged.