Skip to content

Persistence and Data Modeling in ConnectSoft Microservice Template

Purpose & Overview

Persistence is a fundamental infrastructure concern in the ConnectSoft Microservice Template, providing reliable, scalable, and maintainable data storage capabilities. The template treats persistence as an infrastructure service that serves the domain layer while maintaining strict separation of concerns.

Key Principles

The persistence layer follows these core principles:

  • Persistence Ignorance: Domain models remain completely unaware of underlying storage technologies
  • Framework Agnostic: Application and domain layers depend only on abstractions, not specific ORMs or databases
  • Flexibility: Support for multiple persistence technologies (NHibernate for SQL, MongoDB for NoSQL)
  • Testability: Repository interfaces enable easy unit and integration testing
  • Scalability: Support for CQRS, Event Sourcing, and polyglot persistence patterns
  • Transaction Safety: Unit of Work pattern ensures atomic operations and consistency

Persistence Philosophy

Persistence is treated as an infrastructure concern—it serves the domain but never controls or pollutes it. The goal is Persistence Ignorance: business logic should never care about how or where data is stored. This enables flexible, maintainable, and future-proof microservices.

Architecture Overview

Persistence Layer Structure

Application Layer (DomainModel)
    ├── Processors (Commands/Writes)
    └── Retrievers (Queries/Reads)
    ↓ (Uses Repository Interfaces)
Infrastructure Layer (PersistenceModel)
    ├── PersistenceModel (Interfaces)
    │   ├── IMicroserviceAggregateRootsRepository
    │   ├── IMicroserviceAggregateRootsSpecification
    │   └── IUnitOfWork
    ├── PersistenceModel.NHibernate (SQL Implementation)
    │   ├── Repositories (NHibernate-based)
    │   ├── Mappings (Fluent NHibernate)
    │   └── Specifications (LINQ-based)
    └── PersistenceModel.MongoDb (NoSQL Implementation)
        ├── Repositories (MongoDB Driver-based)
        └── Specifications (MongoDB Builder-based)
Database Layer
    ├── SQL Server / PostgreSQL / MySQL (via NHibernate)
    └── MongoDB (via MongoDB Driver)

Project Organization

Project Responsibility
PersistenceModel Repository and specification interfaces (abstraction layer)
PersistenceModel.NHibernate NHibernate-based repository and specification implementations
PersistenceModel.MongoDb MongoDB-based repository and specification implementations
DatabaseModel.Migrations FluentMigrator SQL migrations
DatabaseModel.MongoDb.Migrations MongoDB migrations
Extensions.PersistenceModel Base repository and specification abstractions from ConnectSoft framework

Key Integration Points

Component Layer Responsibility
Repository Interfaces PersistenceModel Define contracts for data access
Repository Implementations PersistenceModel.NHibernate/MongoDb Concrete persistence implementations
Specification Interfaces PersistenceModel Query abstraction contracts
Queryable Specifications PersistenceModel.NHibernate/MongoDb Query execution implementations
Unit of Work Infrastructure Transaction management abstraction
Migrations DatabaseModel Schema evolution management

Repository Pattern

Purpose

The Repository Pattern provides a collection-like abstraction over data persistence, serving as the sole access point for persisting and retrieving aggregates and entities. It enforces Clean Architecture boundaries by keeping persistence details out of the domain and application layers.

See Repository Pattern for detailed information.

Repository Interface

Each aggregate root has its own repository interface:

// IMicroserviceAggregateRootsRepository.cs
namespace ConnectSoft.MicroserviceTemplate.PersistenceModel.Repositories
{
    using System;
    using ConnectSoft.Extensions.PersistenceModel.Repositories;
    using ConnectSoft.MicroserviceTemplate.EntityModel;

    /// <summary>
    /// Mediates between the domain and data mapping layers
    /// using a collection-like interface for accessing
    /// MicroserviceAggregateRoots domain objects.
    /// </summary>
    public interface IMicroserviceAggregateRootsRepository 
        : IGenericRepository<IMicroserviceAggregateRoot, Guid>
    {
    }
}

The interface inherits from IGenericRepository<TEntity, TIdentity> which provides: - CRUD Operations: Insert, Update, Delete, GetById, GetAll - Async Support: All operations have async variants - Specification Pattern: Specify<TSpecification>() method for querying - Expression Queries: Query and QueryAsync methods

Repository Implementations

NHibernate Implementation

// MicroserviceAggregateRootsRepository.cs
namespace ConnectSoft.MicroserviceTemplate.PersistenceModel.NHibernate.Repositories
{
    using System;
    using ConnectSoft.Extensions.PersistenceModel;
    using ConnectSoft.Extensions.PersistenceModel.Repositories;
    using ConnectSoft.Extensions.PersistenceModel.Specifications;
    using ConnectSoft.MicroserviceTemplate.EntityModel;
    using ConnectSoft.MicroserviceTemplate.PersistenceModel.Repositories;

    /// <summary>
    /// Generic repository implementation that provides unified access
    /// to the MicroserviceAggregateRoots entities stored in underlying data storage.
    /// </summary>
    public class MicroserviceAggregateRootsRepository(
        IUnitOfWork unitOfWork, 
        ISpecificationLocator specificationLocator)
        : GenericRepository<IMicroserviceAggregateRoot, Guid>(
            unitOfWork, 
            specificationLocator), 
          IMicroserviceAggregateRootsRepository
    {
        // Inherits all CRUD operations from GenericRepository:
        // - Insert, InsertAsync
        // - Update, UpdateAsync
        // - Delete, DeleteAsync
        // - GetById, GetByIdAsync
        // - GetAll, GetAllAsync
        // - Query, QueryAsync
        // - Specify<T> (Specification Pattern)
    }
}

MongoDB Implementation

// MicroserviceAggregateRootsMongoDbRepository.cs
namespace ConnectSoft.MicroserviceTemplate.PersistenceModel.MongoDb.Repositories
{
    using System;
    using ConnectSoft.Extensions.PersistenceModel;
    using ConnectSoft.Extensions.PersistenceModel.MongoDb.Repositories;
    using ConnectSoft.Extensions.PersistenceModel.Specifications;
    using ConnectSoft.MicroserviceTemplate.EntityModel;
    using ConnectSoft.MicroserviceTemplate.PersistenceModel.Repositories;
    using Microsoft.Extensions.Logging;
    using MongoDB.Driver;

    /// <summary>
    /// MongoDb repository implementation that provides unified access
    /// to the MicroserviceAggregateRoots entities stored in underlying data storage.
    /// </summary>
    public class MicroserviceAggregateRootsMongoDbRepository(
        IUnitOfWork unitOfWork,
        IMongoDatabase mongoDatabase,
        ISpecificationLocator specificationLocator,
        ILogger<MongoDbRepository<IMicroserviceAggregateRoot, Guid>> logger)
        : MongoDbRepository<IMicroserviceAggregateRoot, Guid>(
            unitOfWork, 
            mongoDatabase, 
            specificationLocator, 
            logger), 
          IMicroserviceAggregateRootsRepository
    {
        // Inherits MongoDB-specific implementation
    }
}

Repository Usage in Application Layer

Repositories are used in processors and retrievers:

// DefaultMicroserviceAggregateRootsProcessor.cs
public class DefaultMicroserviceAggregateRootsProcessor : IMicroserviceAggregateRootsProcessor
{
    private readonly IMicroserviceAggregateRootsRepository repository;
    private readonly IUnitOfWork unitOfWork;

    public async Task<IMicroserviceAggregateRoot> CreateMicroserviceAggregateRoot(
        CreateMicroserviceAggregateRootInput input, 
        CancellationToken token = default)
    {
        // Check if entity already exists
        var existing = await this.repository.GetByIdAsync(input.ObjectId, token)
            .ConfigureAwait(false);

        if (existing != null)
        {
            throw new MicroserviceAggregateRootAlreadyExistsException(input.ObjectId);
        }

        // Create new entity
        var newEntity = new MicroserviceAggregateRootEntity
        {
            ObjectId = input.ObjectId
        };

        // Insert within transaction
        this.unitOfWork.ExecuteTransactional(() =>
        {
            this.repository.Insert(newEntity);
        });

        // Publish domain events after successful commit
#if (UseMassTransit || UseNServiceBus)
        await this.eventBus.PublishEvent<MicroserviceAggregateRootCreatedEvent>(
            new MicroserviceAggregateRootCreatedEvent { ObjectId = newEntity.ObjectId },
            token).ConfigureAwait(false);
#endif

        return newEntity;
    }
}

Specification Pattern

Purpose

The Specification Pattern encapsulates query logic and separates business rules from repository implementations. It provides a domain-oriented way to express complex queries while maintaining persistence ignorance.

See Specification Pattern for detailed information.

Specification Interface

Each aggregate has its own specification interface:

// IMicroserviceAggregateRootsSpecification.cs
namespace ConnectSoft.MicroserviceTemplate.PersistenceModel.Specifications
{
    using System;
    using ConnectSoft.Extensions.PersistenceModel.Specifications;
    using ConnectSoft.MicroserviceTemplate.EntityModel;

    /// <summary>
    /// Base interface for the MicroserviceAggregateRoots (domain oriented queries).
    /// </summary>
    public interface IMicroserviceAggregateRootsSpecification 
        : ISpecification<IMicroserviceAggregateRoot, Guid>
    {
        // Inherits fluent query methods from ISpecification:
        // - Where, And, Or, Not
        // - OrderBy, OrderByDescending
        // - Skip, Take
        // - ToListAsync, FirstOrDefaultAsync, AnyAsync, CountAsync
    }
}

Queryable Specification Implementations

NHibernate Queryable Specification

// MicroserviceAggregateRootsQueryableSpecification.cs
public class MicroserviceAggregateRootsQueryableSpecification(
    IUnitOfWorkConvertor unitOfWorkConvertor)
    : QueryableSpecification<IMicroserviceAggregateRoot, Guid>(unitOfWorkConvertor), 
      IMicroserviceAggregateRootsSpecification
{
    // Translates specifications to LINQ queries over NHibernate IQueryable
}

MongoDB Queryable Specification

// MicroserviceAggregateRootsMongoDbQueryableSpecification.cs
public class MicroserviceAggregateRootsMongoDbQueryableSpecification(
    IMongoDatabase mongoDatabase,
    ILogger<MongoDbQueryableSpecification<IMicroserviceAggregateRoot, Guid>> logger)
    : MongoDbQueryableSpecification<IMicroserviceAggregateRoot, Guid>(mongoDatabase, logger), 
      IMicroserviceAggregateRootsSpecification
{
    // Translates specifications to MongoDB filter builders
}

Specification Usage

// Using specifications in retrievers
public async Task<IReadOnlyList<IMicroserviceAggregateRoot>> GetActiveAggregatesAsync(
    CancellationToken ct)
{
    var specification = this.repository.Specify<IMicroserviceAggregateRootsSpecification>()
        .Where(x => x.Status == Status.Active)
        .OrderByDescending(x => x.CreatedOn);

    return await specification.ToListAsync(ct);
}

Unit of Work Pattern

Purpose

The Unit of Work pattern manages transactions and ensures atomicity of operations. It groups multiple repository operations into a single transaction, ensuring all-or-nothing semantics.

IUnitOfWork Interface

The IUnitOfWork interface provides transaction management:

public interface IUnitOfWork
{
    // Execute operations within a transaction
    void ExecuteTransactional(Action action);
    Task ExecuteTransactionalAsync(Func<Task> action, CancellationToken ct = default);

    // Begin explicit transaction
    Task<ITransaction> BeginTransactionAsync(CancellationToken ct = default);
}

Usage in Application Layer

// Using ExecuteTransactional helper
public async Task<IMicroserviceAggregateRoot> CreateMicroserviceAggregateRoot(
    CreateMicroserviceAggregateRootInput input,
    CancellationToken token = default)
{
    MicroserviceAggregateRootEntity newEntity = new MicroserviceAggregateRootEntity();

    this.unitOfWork.ExecuteTransactional(() =>
    {
        newEntity.ObjectId = input.ObjectId;
        this.repository.Insert(newEntity);
    });

    return newEntity;
}

Explicit Transaction Management

// Using explicit transaction
using var transaction = await this.unitOfWork.BeginTransactionAsync(token);
try
{
    await this.repository.InsertAsync(entity1, token);
    await this.repository.UpdateAsync(entity2, token);
    await transaction.CommitAsync(token);
}
catch
{
    // Transaction rolls back automatically on disposal if not committed
    throw;
}

NHibernate Implementation

Overview

NHibernate provides SQL-based persistence with support for SQL Server, PostgreSQL, MySQL, and other relational databases. It uses Fluent NHibernate for code-based mapping configuration.

See NHibernate for detailed information.

Configuration

NHibernate is registered via extension methods:

// MicroserviceRegistrationExtensions.cs
#if UseNHibernate
    services.AddNHibernatePersistenceModel(configuration);
#endif

#if (UseNHibernate || UseMongoDb)
    services.AddPersistenceModel();
#endif

Entity Mappings

Entities are mapped using Fluent NHibernate:

// MicroserviceAggregateRootEntityMap.cs
public class MicroserviceAggregateRootEntityMap : ClassMapping<MicroserviceAggregateRootEntity>
{
    public MicroserviceAggregateRootEntityMap()
    {
        this.Id(x => x.ObjectId, mapper => mapper.Generator(Generators.GuidComb));
        this.Property(x => x.SomeValue, mapper => mapper.Length(500));
        // Additional property mappings...
    }
}

Session Management

  • ISessionFactory: Singleton (created once, reused)
  • ISession: Scoped (one per HTTP request)
  • ITransaction: Managed by Unit of Work pattern

MongoDB Implementation

Overview

MongoDB provides NoSQL document-based persistence with schema flexibility and horizontal scalability. It uses the MongoDB .NET Driver for database operations.

Configuration

MongoDB is registered via extension methods:

// MicroserviceRegistrationExtensions.cs
#if UseMongoDb
    services.AddMongoDbPersistence(configuration);
#endif

#if (UseNHibernate || UseMongoDb)
    services.AddPersistenceModel();
#endif

Connection Setup

MongoDB connection is configured via options:

{
  "PersistenceModel": {
    "MongoDb": {
      "MongoDbConnectionStringKey": "MongoDb",
      "DatabaseName": "MicroserviceTemplateDb"
    }
  },
  "ConnectionStrings": {
    "MongoDb": "mongodb://localhost:27017"
  }
}

Collection Management

Collections are created automatically on first use or via migrations:

// Repository uses collection name
var collection = database.GetCollection<MicroserviceAggregateRoot>("MicroserviceAggregateRoots");

Database Migrations

Purpose

Migrations provide versioned, repeatable, and safe database schema evolution. They ensure consistent schema across environments and enable safe production deployments.

FluentMigrator (SQL Migrations)

FluentMigrator manages SQL database schema evolution using code-first migrations.

Migration Example

// MicroserviceMigration.cs
namespace ConnectSoft.MicroserviceTemplate.DatabaseModel.Migrations
{
    using FluentMigrator;

    [Migration(1)]
    public class MicroserviceMigration : Migration
    {
        internal const string SchemaName = "ConnectSoft.MicroserviceTemplate";

        public override void Up()
        {
            this.Create.Schema(SchemaName);

            this.Create
                .Table("MicroserviceAggregateRoots")
                .InSchema(SchemaName)
                .WithColumn("ObjectId")
                    .AsGuid()
                    .NotNullable()
                    .PrimaryKey();
        }

        public override void Down()
        {
            this.Delete
                .Table("MicroserviceAggregateRoots")
                .InSchema(SchemaName);
            this.Delete.Schema(SchemaName);
        }
    }
}

Migration Registration

// MicroserviceRegistrationExtensions.cs
#if Migrations
    services.AddMicroserviceFluentMigrator(
        configuration, 
        typeof(MicroserviceMigration).Assembly);
#endif

Migration Execution

Migrations run automatically at application startup:

// MicroserviceRegistrationExtensions.cs - UseMicroserviceServices
#if UseNHibernate
    application.RunMicroserviceFluentMigrations();
#endif

MongoDB Migrations

MongoDB migrations manage collection creation, indexes, and schema evolution for document databases.

Migration Example

// MicroserviceMongoDbMigration.cs
namespace ConnectSoft.MicroserviceTemplate.DatabaseModel.MongoDb.Migrations
{
    using CSharpMongoMigrations;

    [Migration(0, "First ConnectSoft.MicroserviceTemplate's MongoDb migration")]
    public class MicroserviceMongoDbMigration : Migration
    {
        public override void Up()
        {
            this.Database.CreateCollection("MicroserviceAggregateRoots");
        }

        public override void Down()
        {
            this.Database.DropCollection("MicroserviceAggregateRoots");
        }
    }
}

Migration Registration

// MicroserviceRegistrationExtensions.cs
#if UseMongoDb
#if Migrations
    services.AddMongoDbMigrator(
        configuration, 
        typeof(MicroserviceMongoDbMigration));
#endif
#endif

Migration Best Practices

Do's

  1. Version Migrations Sequentially

    // ✅ GOOD - Sequential versioning
    [Migration(1)]
    public class CreateTable : Migration { }
    
    [Migration(2)]
    public class AddColumn : Migration { }
    

  2. Always Provide Down Methods

    // ✅ GOOD - Reversible migration
    public override void Up()
    {
        Create.Table("Orders");
    }
    
    public override void Down()
    {
        Delete.Table("Orders");
    }
    

  3. Keep Migrations Atomic

    // ✅ GOOD - Single logical change
    [Migration(5)]
    public class AddEmailColumnToUsers : Migration { }
    

  4. Test Migrations

    // ✅ GOOD - Test migration application
    [TestMethod]
    public async Task ShouldApplyAllMigrationsSuccessfully()
    {
        var runner = GetMigrationRunner();
        await runner.MigrateUpAsync();
    }
    

Don'ts

  1. Don't Modify Existing Migrations

    // ❌ BAD - Never modify migration 1 after it's been applied
    [Migration(1)]
    public class CreateTable : Migration { }
    
    // ✅ GOOD - Create new migration to fix issues
    [Migration(6)]
    public class FixEmailColumnDataType : Migration { }
    

  2. Don't Skip Migration Versions

    // ❌ BAD - Gaps in version numbers
    [Migration(1)]
    public class Migration1 : Migration { }
    
    [Migration(5)]  // Missing 2, 3, 4
    public class Migration5 : Migration { }
    

  3. Don't Include Data Changes in Schema Migrations

    // ❌ BAD - Data manipulation in schema migration
    public override void Up()
    {
        Create.Table("Orders");
        Execute.Sql("UPDATE Orders SET Status = 'Pending'");  // Don't do this
    }
    
    // ✅ GOOD - Separate data migration
    [Migration(10, "DataMigration")]
    public class SetDefaultOrderStatus : Migration { }
    

Data Modeling

Domain Entities

Domain entities are pure POCOs with no persistence dependencies:

// MicroserviceAggregateRootEntity.cs
namespace ConnectSoft.MicroserviceTemplate.EntityModel
{
    using System;
    using ConnectSoft.Extensions.PersistenceModel;

    public class MicroserviceAggregateRootEntity : MicroserviceAggregateRoot, IMicroserviceAggregateRoot
    {
        public Guid ObjectId { get; set; }
        public string? SomeValue { get; set; }
        public DateTimeOffset CreatedOn { get; set; }
    }
}

Characteristics: - No ORM attributes (NHibernate, MongoDB, etc.) - No infrastructure dependencies - Pure domain logic - Implements domain interfaces

Entity Mapping

Mappings are defined separately in the infrastructure layer:

NHibernate Mapping

// MicroserviceAggregateRootEntityMap.cs
public class MicroserviceAggregateRootEntityMap : ClassMapping<MicroserviceAggregateRootEntity>
{
    public MicroserviceAggregateRootEntityMap()
    {
        this.Id(x => x.ObjectId, mapper => mapper.Generator(Generators.GuidComb));
        this.Property(x => x.SomeValue, mapper => mapper.Length(500).Nullable());
        this.Property(x => x.CreatedOn, mapper => mapper.NotNullable());
    }
}

MongoDB Mapping

MongoDB uses convention-based mapping by default, with optional explicit configuration:

// MongoDB automatically maps:
// - ObjectId → _id field
// - Properties → Document fields
// - Collection name from class name (or attribute)

Polyglot Persistence

The template supports using multiple persistence technologies simultaneously:

// Register both NHibernate and MongoDB
#if UseNHibernate
    services.AddNHibernatePersistenceModel(configuration);
#endif

#if UseMongoDb
    services.AddMongoDbPersistence(configuration);
#endif

#if (UseNHibernate || UseMongoDb)
    services.AddPersistenceModel();
#endif

Use Cases: - SQL for transactional aggregates (orders, payments) - MongoDB for document-heavy aggregates (product catalogs, user profiles) - Different databases for different bounded contexts

Service Registration

Persistence Model Registration

All persistence services are registered via AddPersistenceModel():

// PersistenceModelExtensions.cs
internal static IServiceCollection AddPersistenceModel(this IServiceCollection services)
{
    ArgumentNullException.ThrowIfNull(services);

    // Register repositories based on selected persistence technology
#if UseNHibernate
    services.AddScoped<IMicroserviceAggregateRootsRepository, MicroserviceAggregateRootsRepository>();
    services.AddScoped<IMicroserviceAggregateRootsQueryableSpecification, MicroserviceAggregateRootsQueryableSpecification>();
#elif UseMongoDb
    services.AddScoped<IMicroserviceAggregateRootsRepository, MicroserviceAggregateRootsMongoDbRepository>();
    services.AddScoped<IMicroserviceAggregateRootsQueryableSpecification, MicroserviceAggregateRootsMongoDbQueryableSpecification>();
#endif

    return services;
}

Keyed Services (Multi-Database)

For scenarios with multiple databases, repositories can use keyed services:

// Register NHibernate repository with key
services.AddKeyedScoped<IUnitOfWork, NHibernateUnitOfWork>(MicroserviceConstants.NHibernateDIKey);
services.AddKeyedScoped<IMicroserviceAggregateRootsRepository, MicroserviceAggregateRootsKeyedRepository>(
    MicroserviceConstants.NHibernateDIKey);

// Register MongoDB repository with key
services.AddKeyedScoped<IUnitOfWork, MongoDbUnitOfWork>(MicroserviceConstants.MongoDbDIKey);
services.AddKeyedScoped<IMicroserviceAggregateRootsRepository, MicroserviceAggregateRootsMongoDbKeyedRepository>(
    MicroserviceConstants.MongoDbDIKey);

Usage with keyed services:

public class MyService
{
    private readonly IMicroserviceAggregateRootsRepository sqlRepository;
    private readonly IMicroserviceAggregateRootsRepository mongoRepository;

    public MyService(
        [FromKeyedServices(MicroserviceConstants.NHibernateDIKey)] 
        IMicroserviceAggregateRootsRepository sqlRepository,
        [FromKeyedServices(MicroserviceConstants.MongoDbDIKey)] 
        IMicroserviceAggregateRootsRepository mongoRepository)
    {
        this.sqlRepository = sqlRepository;
        this.mongoRepository = mongoRepository;
    }
}

Complete Persistence Flow

Creating an Aggregate

flowchart TD
    API[API Request] --> Processor[Processor]
    Processor --> Validate[Validate Input]
    Validate --> CheckExists[Check if Exists]
    CheckExists --> CreateEntity[Create Entity]
    CreateEntity --> BeginTransaction[Begin Transaction]
    BeginTransaction --> Repository[Repository.Insert]
    Repository --> Database[Database Write]
    Database --> Commit[Commit Transaction]
    Commit --> PublishEvent[Publish Domain Event]
    PublishEvent --> Return[Return Entity]
Hold "Alt" / "Option" to enable pan & zoom

Querying with Specifications

flowchart TD
    Retriever[Retriever] --> CreateSpec[Create Specification]
    CreateSpec --> Repository[Repository.Specify]
    Repository --> QueryableSpec[Queryable Specification]
    QueryableSpec --> Translate[Translate to DB Query]
    Translate --> Execute[Execute Query]
    Execute --> Database[Database]
    Database --> Results[Return Results]
Hold "Alt" / "Option" to enable pan & zoom

Full Operational Flow

  1. API Request arrives at controller
  2. Controller maps DTO to Application Input
  3. Processor/Retriever receives input
  4. Validation occurs (FluentValidation)
  5. Business Logic executes
  6. Unit of Work begins transaction
  7. Repository performs persistence operations
  8. Transaction commits
  9. Domain Events published (if any)
  10. Metrics recorded
  11. Response returned to client

Best Practices

Do's

  1. Always Operate at Aggregate Root Level

    // ✅ GOOD - Persist aggregate root
    await repository.InsertAsync(aggregateRoot);
    
    // ❌ BAD - Don't persist child entities directly
    await repository.InsertAsync(childEntity);  // Never!
    

  2. Use Unit of Work for Transactions

    // ✅ GOOD - Transactional operation
    unitOfWork.ExecuteTransactional(() =>
    {
        repository.Insert(entity1);
        repository.Update(entity2);
    });
    

  3. Use Specifications for Queries

    // ✅ GOOD - Domain-oriented query
    var spec = repository.Specify<IMicroserviceAggregateRootsSpecification>()
        .Where(x => x.Status == Status.Active);
    var results = await spec.ToListAsync();
    

  4. Handle Null Returns

    // ✅ GOOD - Explicit null handling
    var entity = await repository.GetByIdAsync(id);
    if (entity == null)
    {
        throw new MicroserviceAggregateRootNotFoundException(id);
    }
    

  5. Use Async Methods

    // ✅ GOOD - Async operations
    await repository.InsertAsync(entity, cancellationToken);
    
    // ❌ BAD - Synchronous operations
    repository.Insert(entity);  // Avoid in async contexts
    

  6. Validate Before Persistence

    // ✅ GOOD - Validate before saving
    await validator.ValidateAndThrowAsync(input);
    await repository.InsertAsync(entity);
    

Don'ts

  1. Don't Expose IQueryable or DbSet

    // ❌ BAD - Leaks persistence details
    public IQueryable<Entity> GetQueryable() { }
    
    // ✅ GOOD - Use repository interface
    public async Task<IList<Entity>> GetAllAsync() { }
    

  2. Don't Access Child Entities Directly

    // ❌ BAD - Bypassing aggregate root
    var child = await repository.GetChildEntityAsync(childId);
    
    // ✅ GOOD - Access through aggregate
    var aggregate = await repository.GetByIdAsync(aggregateId);
    var child = aggregate.Children.First(c => c.Id == childId);
    

  3. Don't Perform Business Logic in Repositories

    // ❌ BAD - Business logic in repository
    public async Task<Entity> CreateEntityAsync(Input input)
    {
        if (input.Value < 0) throw new Exception();  // Business rule!
        // ...
    }
    
    // ✅ GOOD - Repository is pure data access
    public async Task InsertAsync(Entity entity) { }
    

  4. Don't Forget Transaction Boundaries

    // ❌ BAD - No transaction
    repository.Insert(entity1);
    repository.Update(entity2);  // What if this fails?
    
    // ✅ GOOD - Transactional
    unitOfWork.ExecuteTransactional(() =>
    {
        repository.Insert(entity1);
        repository.Update(entity2);
    });
    

  5. Don't Mix Sync and Async

    // ❌ BAD - Mixing patterns
    repository.Insert(entity);  // Sync
    await repository.UpdateAsync(entity);  // Async
    
    // ✅ GOOD - Consistent async
    await repository.InsertAsync(entity);
    await repository.UpdateAsync(entity);
    

  6. Don't Store Secrets in Entity Properties

    // ❌ BAD - Sensitive data in entity
    public class UserEntity
    {
        public string Password { get; set; }  // Should be hashed separately
    }
    

Testing

Unit Testing Repositories

Mock repository interfaces in unit tests:

[TestMethod]
public async Task CreateAggregate_Should_Insert_Into_Repository()
{
    // Arrange
    var mockRepository = new Mock<IMicroserviceAggregateRootsRepository>();
    var mockUnitOfWork = new Mock<IUnitOfWork>();

    var processor = new DefaultMicroserviceAggregateRootsProcessor(
        Mock.Of<ILogger<DefaultMicroserviceAggregateRootsProcessor>>(),
        TimeProvider.System,
        mockRepository.Object,
        mockUnitOfWork.Object,
        // ... other dependencies
    );

    var input = new CreateMicroserviceAggregateRootInput 
    { 
        ObjectId = Guid.NewGuid() 
    };

    // Act
    var result = await processor.CreateMicroserviceAggregateRoot(input);

    // Assert
    mockRepository.Verify(r => r.Insert(It.IsAny<IMicroserviceAggregateRoot>()), Times.Once);
    mockUnitOfWork.Verify(u => u.ExecuteTransactional(It.IsAny<Action>()), Times.Once);
}

Unit Testing Specifications

Test specifications independently:

[TestMethod]
public void Specification_Should_Filter_Correctly()
{
    // Arrange
    var entities = new List<MicroserviceAggregateRootEntity>
    {
        new() { SomeValue = "Value1" },
        new() { SomeValue = "Target" },
        new() { SomeValue = "Value2" }
    };

    var spec = new MicroserviceAggregateRootsByValueSpecification("Target");

    // Act
    var result = entities.AsQueryable()
        .Where(spec.ToExpression())
        .ToList();

    // Assert
    Assert.AreEqual(1, result.Count);
    Assert.AreEqual("Target", result[0].SomeValue);
}

Integration Testing

Test against real databases (or in-memory/test containers):

[TestMethod]
public async Task Repository_Should_Save_And_Retrieve_Entity()
{
    // Arrange
    var services = new ServiceCollection();
    services.AddNHibernatePersistenceModel(/* test configuration */);
    var provider = services.BuildServiceProvider();

    var repository = provider.GetRequiredService<IMicroserviceAggregateRootsRepository>();
    var unitOfWork = provider.GetRequiredService<IUnitOfWork>();

    var entity = new MicroserviceAggregateRootEntity
    {
        ObjectId = Guid.NewGuid(),
        SomeValue = "TestValue"
    };

    // Act
    unitOfWork.ExecuteTransactional(() =>
    {
        repository.Insert(entity);
    });

    var retrieved = await repository.GetByIdAsync(entity.ObjectId);

    // Assert
    Assert.IsNotNull(retrieved);
    Assert.AreEqual(entity.SomeValue, retrieved.SomeValue);
}

Migration Testing

Test migrations independently:

[TestMethod]
public async Task Should_Apply_All_Migrations_Successfully()
{
    // Arrange
    var runner = GetMigrationRunner();

    // Act
    await runner.MigrateUpAsync();

    // Assert
    // Verify schema exists, tables created, etc.
}

Common Scenarios

Scenario 1: Creating a New Aggregate

public async Task<IMicroserviceAggregateRoot> CreateMicroserviceAggregateRoot(
    CreateMicroserviceAggregateRootInput input,
    CancellationToken token = default)
{
    // 1. Validate input
    await this.validator.ValidateAndThrowAsync(input, token);

    // 2. Check if already exists
    var existing = await this.repository.GetByIdAsync(input.ObjectId, token);
    if (existing != null)
    {
        throw new MicroserviceAggregateRootAlreadyExistsException(input.ObjectId);
    }

    // 3. Create entity
    var newEntity = new MicroserviceAggregateRootEntity
    {
        ObjectId = input.ObjectId
    };

    // 4. Persist within transaction
    this.unitOfWork.ExecuteTransactional(() =>
    {
        this.repository.Insert(newEntity);
    });

    // 5. Publish events (after commit)
    await this.eventBus.PublishEvent(new MicroserviceAggregateRootCreatedEvent 
    { 
        ObjectId = newEntity.ObjectId 
    }, token);

    return newEntity;
}

Scenario 2: Querying with Multiple Conditions

public async Task<IReadOnlyList<IMicroserviceAggregateRoot>> SearchAggregatesAsync(
    string? searchTerm,
    Status? status,
    DateTimeOffset? fromDate,
    CancellationToken ct)
{
    var specification = this.repository.Specify<IMicroserviceAggregateRootsSpecification>();

    if (!string.IsNullOrWhiteSpace(searchTerm))
    {
        specification = specification.Where(x => x.SomeValue.Contains(searchTerm));
    }

    if (status.HasValue)
    {
        specification = specification.And(x => x.Status == status.Value);
    }

    if (fromDate.HasValue)
    {
        specification = specification.And(x => x.CreatedOn >= fromDate.Value);
    }

    return await specification
        .OrderByDescending(x => x.CreatedOn)
        .ToListAsync(ct);
}

Scenario 3: Batch Operations

public async Task CreateMultipleAggregatesAsync(
    IEnumerable<CreateMicroserviceAggregateRootInput> inputs,
    CancellationToken token = default)
{
    this.unitOfWork.ExecuteTransactional(() =>
    {
        foreach (var input in inputs)
        {
            var entity = new MicroserviceAggregateRootEntity
            {
                ObjectId = input.ObjectId
            };
            this.repository.Insert(entity);
        }
    });
}

Scenario 4: Pagination

public async Task<PagedResult<IMicroserviceAggregateRoot>> GetAggregatesPageAsync(
    int pageNumber,
    int pageSize,
    CancellationToken ct)
{
    var skip = (pageNumber - 1) * pageSize;

    var specification = this.repository.Specify<IMicroserviceAggregateRootsSpecification>()
        .OrderBy(x => x.CreatedOn)
        .Skip(skip)
        .Take(pageSize);

    var items = await specification.ToListAsync(ct);
    var totalCount = await this.repository.Specify<IMicroserviceAggregateRootsSpecification>()
        .CountAsync(ct);

    return new PagedResult<IMicroserviceAggregateRoot>
    {
        Items = items,
        TotalCount = totalCount,
        PageNumber = pageNumber,
        PageSize = pageSize
    };
}

Troubleshooting

Issue: Repository Not Found

Symptom: InvalidOperationException: No service for type 'IMicroserviceAggregateRootsRepository'

Solutions: 1. Verify AddPersistenceModel() is called in startup 2. Check conditional compilation directives (#if UseNHibernate or #if UseMongoDb) 3. Ensure repository implementation is registered in DI container

Issue: Transaction Not Committing

Symptom: Changes not persisted to database

Solutions: 1. Verify unitOfWork.ExecuteTransactional() is called 2. Check for exceptions before commit 3. Ensure transaction is not being rolled back 4. Verify database connection is valid

Issue: Specification Not Working

Symptom: Specification returns wrong results or errors

Solutions: 1. Verify specification implementation is registered 2. Check specification expression is valid 3. Test specification independently with unit tests 4. Verify queryable specification is correctly implemented

Issue: Migration Fails

Symptom: Application fails to start with migration errors

Solutions: 1. Check migration version numbers are sequential 2. Verify Up() and Down() methods are correct 3. Test migrations in development environment first 4. Check database connection string is correct 5. Verify migration assembly is registered

Issue: Performance Issues

Symptom: Slow queries, high database load

Solutions: 1. Add Indexes: Create indexes for frequently queried columns 2. Use Projections: Select only needed fields 3. Implement Pagination: Avoid loading large result sets 4. Enable Caching: Use second-level cache for read-heavy scenarios 5. Optimize Queries: Review generated SQL/MongoDB queries

Summary

Persistence and data modeling in the ConnectSoft Microservice Template provides:

  • Persistence Ignorance: Domain models remain pure and framework-agnostic
  • Repository Pattern: Clean abstraction over data access
  • Specification Pattern: Domain-oriented query abstraction
  • Unit of Work: Transaction management and atomicity
  • Multi-Persistence Support: NHibernate (SQL) and MongoDB (NoSQL)
  • Migrations: Versioned, automated schema evolution
  • Testability: Interfaces enable easy unit and integration testing
  • Flexibility: Easy to switch or add persistence technologies

By following these patterns, teams can:

  • Build Maintainable Services: Clear separation of concerns
  • Scale Effectively: Support for multiple databases and polyglot persistence
  • Test Confidently: Repository and specification interfaces enable comprehensive testing
  • Evolve Safely: Migrations ensure safe schema evolution
  • Adapt Quickly: Easy to swap or add persistence technologies

The persistence layer is architected as a strategic enabler—not an afterthought—providing the foundation for reliable, scalable, and maintainable microservices.