Skip to content

Specification Pattern in ConnectSoft Microservice Template

Purpose & Overview

The Specification Pattern is a powerful Domain-Driven Design (DDD) pattern used in the ConnectSoft Microservice Template to encapsulate query logic and separate business rules from repository implementations. It provides a domain-oriented way to express complex queries while maintaining persistence ignorance.

Why Specification Pattern?

The Specification Pattern offers several key advantages:

  • Encapsulation: Query logic is isolated from repositories and services, making it reusable and testable
  • Composability: Simple specifications can be combined (AND, OR, NOT) to create complex queries
  • Reusability: Specifications can be shared across multiple services and use cases
  • Testability: Specifications can be unit tested independently without database dependencies
  • Persistence Ignorance: Specifications express business intent, not SQL or MongoDB syntax
  • Readability: Specifications use domain language, making queries self-documenting

How It's Implemented in Template

Architecture Overview

Specifications work in conjunction with the Repository Pattern:

Domain/Application Layer
    ↓ (uses)
Repository Interface
    ↓ (uses)
Specification Interface
    ↓ (implemented by)
Queryable Specification (NHibernate/MongoDB)
    ↓ (translates to)
Database Query (LINQ/MongoDB Builder)

Core Components

1. Base Specification Interface

The template uses ISpecification<TEntity, TIdentity> from ConnectSoft.Extensions.PersistenceModel:

public interface ISpecification<TEntity, TIdentity>
    where TEntity : class, IGenericEntity<TIdentity>
{
    // Fluent API for building queries
    ISpecification<TEntity, TIdentity> Where(Expression<Func<TEntity, bool>> predicate);
    ISpecification<TEntity, TIdentity> And(Expression<Func<TEntity, bool>> predicate);
    ISpecification<TEntity, TIdentity> Or(Expression<Func<TEntity, bool>> predicate);
    ISpecification<TEntity, TIdentity> Not(Expression<Func<TEntity, bool>> predicate);

    // Sorting
    ISpecification<TEntity, TIdentity> OrderBy<TKey>(Expression<Func<TEntity, TKey>> keySelector);
    ISpecification<TEntity, TIdentity> OrderByDescending<TKey>(Expression<Func<TEntity, TKey>> keySelector);

    // Paging
    ISpecification<TEntity, TIdentity> Skip(int count);
    ISpecification<TEntity, TIdentity> Take(int count);

    // Execution
    Task<IList<TEntity>> ToListAsync(CancellationToken ct = default);
    Task<TEntity?> FirstOrDefaultAsync(CancellationToken ct = default);
    Task<bool> AnyAsync(CancellationToken ct = default);
    Task<int> CountAsync(CancellationToken ct = default);
}

2. Aggregate-Specific Specification Interface

Each aggregate has its own specification interface:

// IMicroserviceAggregateRootsSpecification.cs
public interface IMicroserviceAggregateRootsSpecification 
    : ISpecification<IMicroserviceAggregateRoot, Guid>
{
    // Inherits all fluent query methods from ISpecification
}

3. Queryable Specification Implementations

NHibernate Implementation:

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

MongoDB Implementation:

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

Project Structure

Project Responsibility
PersistenceModel Defines specification interfaces (IMicroserviceAggregateRootsSpecification)
PersistenceModel.NHibernate LINQ-based queryable specification implementation
PersistenceModel.MongoDb MongoDB builder-based queryable specification
Extensions.PersistenceModel Base ISpecification and queryable abstractions

Code Examples

Basic Specification Usage

Simple Query

public async Task<IReadOnlyList<IMicroserviceAggregateRoot>> GetActiveAggregatesAsync(
    CancellationToken ct)
{
    var specification = repository.Specify<IMicroserviceAggregateRootsSpecification>()
        .Where(x => x.Status == Status.Active);

    return await specification.ToListAsync(ct);
}

Query with Multiple Conditions

public async Task<IReadOnlyList<IMicroserviceAggregateRoot>> GetRecentActiveAggregatesAsync(
    DateTimeOffset since,
    CancellationToken ct)
{
    var specification = repository.Specify<IMicroserviceAggregateRootsSpecification>()
        .Where(x => x.Status == Status.Active)
        .And(x => x.CreatedOn >= since);

    return await specification.ToListAsync(ct);
}

Query with Sorting

public async Task<IReadOnlyList<IMicroserviceAggregateRoot>> GetTopRecentAggregatesAsync(
    int count,
    CancellationToken ct)
{
    var specification = repository.Specify<IMicroserviceAggregateRootsSpecification>()
        .OrderByDescending(x => x.CreatedOn)
        .Take(count);

    return await specification.ToListAsync(ct);
}

Paginated Query

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

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

    return await specification.ToListAsync(ct);
}

Complex Specifications

Combining Multiple Conditions

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

    if (!string.IsNullOrWhiteSpace(searchTerm))
    {
        specification = specification.Where(x => 
            x.Name.Contains(searchTerm) || 
            x.Description.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);
}

Using OR Conditions

public async Task<IReadOnlyList<IMicroserviceAggregateRoot>> GetAggregatesInStatusesAsync(
    Status[] statuses,
    CancellationToken ct)
{
    var specification = repository.Specify<IMicroserviceAggregateRootsSpecification>();

    // Build OR chain
    Expression<Func<IMicroserviceAggregateRoot, bool>>? predicate = null;
    foreach (var status in statuses)
    {
        var statusExpr = (Expression<Func<IMicroserviceAggregateRoot, bool>>)
            (x => x.Status == status);

        predicate = predicate == null 
            ? statusExpr 
            : CombineWithOr(predicate, statusExpr);
    }

    if (predicate != null)
    {
        specification = specification.Where(predicate);
    }

    return await specification.ToListAsync(ct);
}

Checking Existence

public async Task<bool> HasActiveAggregatesAsync(CancellationToken ct)
{
    var specification = repository.Specify<IMicroserviceAggregateRootsSpecification>()
        .Where(x => x.Status == Status.Active);

    return await specification.AnyAsync(ct);
}

Getting First Matching Entity

public async Task<IMicroserviceAggregateRoot?> GetFirstActiveAggregateAsync(
    CancellationToken ct)
{
    var specification = repository.Specify<IMicroserviceAggregateRootsSpecification>()
        .Where(x => x.Status == Status.Active)
        .OrderBy(x => x.CreatedOn);

    return await specification.FirstOrDefaultAsync(ct);
}

Counting Results

public async Task<int> CountActiveAggregatesAsync(CancellationToken ct)
{
    var specification = repository.Specify<IMicroserviceAggregateRootsSpecification>()
        .Where(x => x.Status == Status.Active);

    return await specification.CountAsync(ct);
}

Configuration

Dependency Injection Setup

Specifications are registered in the ApplicationModel project:

// NHibernateExtensions.cs
public static IServiceCollection AddMicroserviceNHibernate(
    this IServiceCollection services)
{
    // Register queryable specification for NHibernate
    services.AddScoped<IMicroserviceAggregateRootsSpecification, 
        MicroserviceAggregateRootsQueryableSpecification>();

    // Register specification locator
    services.AddScoped<ISpecificationLocator, SpecificationLocator>();

    return services;
}

// MongoDbExtensions.cs
public static IServiceCollection AddMicroserviceMongoDb(
    this IServiceCollection services)
{
    // Register queryable specification for MongoDB
    services.AddScoped<IMicroserviceAggregateRootsSpecification, 
        MicroserviceAggregateRootsMongoDbQueryableSpecification>();

    return services;
}

Keyed Services for Multiple Persistence

When using multiple persistence technologies:

// NHibernate keyed specification
services.AddKeyedScoped<IMicroserviceAggregateRootsSpecification, 
    MicroserviceAggregateRootsQueryableKeyedSpecification>(
    MicroserviceConstants.NHibernateDIKey);

// MongoDB keyed specification
services.AddKeyedScoped<IMicroserviceAggregateRootsSpecification, 
    MicroserviceAggregateRootsMongoDbQueryableKeyedSpecification>(
    MicroserviceConstants.MongoDbDIKey);

Best Practices

Do's

  1. Use specifications for all dynamic queries
  2. Encapsulate query logic in specifications
  3. Keep repositories focused on CRUD operations

  4. Name specifications clearly

  5. Use domain language that reflects business intent
  6. Example: GetActiveOrdersByCustomerSpecification instead of GetOrders1

  7. Compose specifications when needed

  8. Build complex queries from simple building blocks
  9. Use AND, OR, NOT operators for composition

  10. Keep specifications pure

  11. No side effects or business logic
  12. No direct database access or external service calls

  13. Test specifications independently

  14. Unit test specification logic without database
  15. Verify predicates behave correctly

Don'ts

  1. Don't put business logic in specifications
  2. Specifications are for querying, not processing
  3. Business logic belongs in domain services

  4. Don't create specifications for simple single-use queries

  5. Use expression predicates directly for simple cases
  6. Specifications are for reusable, complex queries

  7. Don't expose database-specific syntax

  8. Keep specifications persistence-agnostic
  9. Let infrastructure layer handle translation

  10. Don't nest specifications too deeply

  11. Keep specifications readable and maintainable
  12. Consider extracting complex nested logic into separate specifications

  13. Don't forget to handle null cases

  14. Always check for null when using FirstOrDefaultAsync
  15. Handle empty result sets appropriately

Integration Points

Repository Integration

Specifications are accessed through repositories:

// Repository provides Specify method
var specification = repository.Specify<IMicroserviceAggregateRootsSpecification>()
    .Where(x => x.Status == Status.Active);

Unit of Work

Specifications execute queries through the Unit of Work pattern:

// Specifications use IUnitOfWorkConvertor to access queryable sources
// This ensures proper session/database context management

Query Translation

NHibernate Path: - Specification → LINQ Expression → NHibernate IQueryable → SQL

MongoDB Path: - Specification → MongoDB Filter Builder → MongoDB Query → Document Query

Common Scenarios

Scenario 1: Dynamic Search with Multiple Filters

public async Task<SearchResult<IMicroserviceAggregateRoot>> SearchAsync(
    SearchRequest request,
    CancellationToken ct)
{
    var specification = repository.Specify<IMicroserviceAggregateRootsSpecification>();

    // Apply filters dynamically
    if (!string.IsNullOrWhiteSpace(request.Name))
    {
        specification = specification.Where(x => x.Name.Contains(request.Name));
    }

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

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

    // Get total count
    var totalCount = await specification.CountAsync(ct);

    // Apply pagination and sorting
    var results = await specification
        .OrderByDescending(x => x.CreatedOn)
        .Skip((request.Page - 1) * request.PageSize)
        .Take(request.PageSize)
        .ToListAsync(ct);

    return new SearchResult<IMicroserviceAggregateRoot>
    {
        Items = results,
        TotalCount = totalCount,
        Page = request.Page,
        PageSize = request.PageSize
    };
}

Scenario 2: Reusable Specification Classes

For frequently used queries, create dedicated specification classes:

public class ActiveAggregatesSpecification
{
    public static IMicroserviceAggregateRootsSpecification Create(
        IMicroserviceAggregateRootsRepository repository)
    {
        return repository.Specify<IMicroserviceAggregateRootsSpecification>()
            .Where(x => x.Status == Status.Active);
    }
}

// Usage
var activeAggregates = await ActiveAggregatesSpecification
    .Create(repository)
    .ToListAsync(ct);

Scenario 3: Complex Business Rule Queries

public async Task<IReadOnlyList<IMicroserviceAggregateRoot>> GetEligibleForProcessingAsync(
    CancellationToken ct)
{
    var specification = repository.Specify<IMicroserviceAggregateRootsSpecification>()
        .Where(x => x.Status == Status.Pending)
        .And(x => x.CreatedOn <= DateTimeOffset.UtcNow.AddMinutes(-5)) // At least 5 minutes old
        .And(x => x.AttemptCount < 3) // Not exceeded retry limit
        .And(x => x.NextProcessingTime <= DateTimeOffset.UtcNow); // Ready for processing

    return await specification
        .OrderBy(x => x.CreatedOn)
        .Take(100) // Process in batches
        .ToListAsync(ct);
}

Troubleshooting

Issue: Specification not returning expected results

Cause: Predicate logic may be incorrect or conditions not properly combined.

Solution: Test specification predicates independently:

// Test the predicate
var predicate = (Expression<Func<IMicroserviceAggregateRoot, bool>>)
    (x => x.Status == Status.Active && x.CreatedOn >= DateTimeOffset.UtcNow.AddDays(-7));

// Verify logic with in-memory test data
var testData = new List<IMicroserviceAggregateRoot> { /* ... */ };
var compiled = predicate.Compile();
var results = testData.Where(compiled).ToList();

Issue: Performance issues with large datasets

Cause: Missing indexes on queried fields or inefficient predicate ordering.

Solution: - Ensure database indexes exist for frequently queried fields - Optimize predicate order (most selective conditions first) - Use pagination for large result sets

Issue: Specification methods not available

Cause: Specification interface may not be properly registered or wrong interface used.

Solution: Verify DI registration:

// Ensure specification implementation is registered
services.AddScoped<IMicroserviceAggregateRootsSpecification, 
    MicroserviceAggregateRootsQueryableSpecification>();

Issue: OR conditions not working as expected

Cause: OR conditions require careful predicate combination.

Solution: Build OR conditions explicitly:

// For multiple OR conditions, combine predicates
var statuses = new[] { Status.Active, Status.Pending };
var specification = repository.Specify<IMicroserviceAggregateRootsSpecification>();

// Use multiple Where calls or combine expressions manually
specification = specification.Where(x => statuses.Contains(x.Status));

References

Summary

The Specification Pattern in ConnectSoft Microservice Template:

  • Provides domain-oriented query abstraction
  • Encapsulates reusable query logic
  • Supports fluent, composable queries
  • Maintains persistence ignorance
  • Enables testable, maintainable code
  • Works seamlessly with Repository Pattern
  • Translates to efficient database queries (LINQ/MongoDB)

This pattern is essential for building maintainable, testable microservices where query logic can evolve independently of business logic and persistence implementations.