Skip to content

Domain Model in ConnectSoft Templates

Purpose & Overview

The Domain Model in ConnectSoft Templates represents the application layer that orchestrates domain operations, validates business rules, and coordinates between the domain entities and infrastructure components. It is organized into two projects: DomainModel (interfaces and contracts) and DomainModel.Impl (implementations).

The domain model provides:

  • Application Services: Processors for mutations, Retrievers for queries, and Use Cases for business scenarios
  • Input/Output Models: Transient objects used for data transfer within the application layer
  • Validation: FluentValidation validators for input validation
  • Transaction Management: Integration with Unit of Work pattern
  • Event Publishing: Integration with messaging frameworks (MassTransit/NServiceBus)
  • Observability: Structured logging, metrics, and distributed tracing

Domain Model Layer

The Domain Model sits between the Presentation Layer (APIs) and the Domain Entities (EntityModel), orchestrating business operations while respecting Clean Architecture boundaries.

Project Structure

The domain model is organized into two projects:

DomainModel Project

Contains interfaces, contracts, and input/output models:

DomainModel/
├── Interfaces/
│   ├── IAggregateRootsProcessor.cs
│   ├── IAggregateRootsRetriever.cs
│   └── IUseCase.cs
├── Input Models/
│   ├── CreateAggregateRootInput.cs
│   ├── DeleteAggregateRootInput.cs
│   ├── GetAggregateRootDetailsInput.cs
│   └── UseCaseInput.cs
└── Output Models/
    └── UseCaseOutput.cs

DomainModel.Impl Project

Contains implementations of domain services and validators:

DomainModel.Impl/
├── Processors/
│   └── DefaultAggregateRootsProcessor.cs
├── Retrievers/
│   └── DefaultAggregateRootsRetriever.cs
├── Use Cases/
│   └── FeatureAUseCaseAUseCase.cs
└── Validators/
    ├── CreateAggregateRootInputValidator.cs
    ├── DeleteAggregateRootInputValidator.cs
    ├── GetAggregateRootDetailsInputValidator.cs
    └── FeatureAUseCaseAUseCaseInputValidator.cs

Core Components

1. Domain Service Interfaces

Domain service interfaces define contracts for business operations:

IAggregateRootsProcessor

Handles mutations (Create, Update, Delete operations):

namespace ConnectSoft.Template.DomainModel
{
    using System.Threading;
    using System.Threading.Tasks;
    using ConnectSoft.Extensions.DomainModel;
    using ConnectSoft.Template.EntityModel;

    /// <summary>
    /// This interface provides methods to process, manage and store AggregateRoots.
    /// </summary>
    public interface IAggregateRootsProcessor : IDomainService
    {
        /// <summary>
        /// Process, create and store a AggregateRoots.
        /// </summary>
        /// <param name="input">The create AggregateRoot's input details.</param>
        /// <param name="token">Cancellation token.</param>
        /// <returns>Returns a created AggregateRoot details.</returns>
        Task<IAggregateRoot> CreateAggregateRoot(
            CreateAggregateRootInput input, 
            CancellationToken token = default);

        /// <summary>
        /// Delete a given AggregateRoot.
        /// </summary>
        /// <param name="input">The create AggregateRoot's input details.</param>
        /// <param name="token">Cancellation token.</param>
        /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
        Task DeleteAggregateRoot(
            DeleteAggregateRootInput input, 
            CancellationToken token = default);
    }
}

IAggregateRootsRetriever

Handles queries (Read operations):

namespace ConnectSoft.Template.DomainModel
{
    using System.Threading;
    using System.Threading.Tasks;
    using ConnectSoft.Extensions.DomainModel;
    using ConnectSoft.Template.EntityModel;

    /// <summary>
    /// This interface provides operations to retrieve AggregateRoots.
    /// </summary>
    public interface IAggregateRootsRetriever : IDomainService
    {
        /// <summary>
        /// Gets AggregateRoot details.
        /// </summary>
        /// <param name="input">The input details.</param>
        /// <param name="token">Cancellation token.</param>
        /// <returns>Returns a found AggregateRoot.</returns>
        Task<IAggregateRoot> GetAggregateRootDetails(
            GetAggregateRootDetailsInput input, 
            CancellationToken token = default);
    }
}

IFeatureAUseCaseAUseCase

Example use case interface for business scenarios:

namespace ConnectSoft.Template.DomainModel
{
    using ConnectSoft.Extensions.DomainModel;

    /// <summary>
    /// FeatureAUseCaseA's description use case contract.
    /// </summary>
    public interface IFeatureAUseCaseAUseCase : IUseCase<FeatureAUseCaseAInput, FeatureAUseCaseAOutput>
    {
    }
}

2. Input/Output Models

Input and output models are transient objects used for data transfer:

CreateAggregateRootInput

namespace ConnectSoft.Template.DomainModel
{
    using System;

    /// <summary>
    /// Create AggregateRoot details input -
    /// transient entity used in business layer components as input argument(s).
    /// </summary>
    public class CreateAggregateRootInput
    {
        /// <summary>
        /// Gets or sets an object identifier.
        /// </summary>
        public Guid ObjectId { get; set; }
    }
}

FeatureAUseCaseAInput and FeatureAUseCaseAOutput

namespace ConnectSoft.Template.DomainModel
{
    /// <summary>
    /// FeatureAUseCaseA's description input -
    /// transient entity used in business layer components as input argument(s).
    /// </summary>
    public class FeatureAUseCaseAInput
    {
    }

    /// <summary>
    /// FeatureAUseCaseA's description output -
    /// transient entity used in business layer components as output argument(s).
    /// </summary>
    public class FeatureAUseCaseAOutput
    {
    }
}

3. Processor Implementation

The processor orchestrates aggregate creation, deletion, and business rule enforcement:

namespace ConnectSoft.Template.DomainModel.Impl
{
    using System;
    using System.Collections.Generic;
    using System.Threading.Tasks;
    using ConnectSoft.Extensions.PersistenceModel;
    using ConnectSoft.Template.EntityModel;
    using ConnectSoft.Template.EntityModel.PocoEntities;
    using ConnectSoft.Template.Metrics;
    using ConnectSoft.Template.PersistenceModel.Repositories;
    using FluentValidation;
    using Microsoft.Extensions.Logging;
#if (UseMassTransit || UseNServiceBus)
    using ConnectSoft.Extensions.MessagingModel;
    using ConnectSoft.Template.MessagingModel;
#endif
    using System.Diagnostics;
    using System.Threading;
    using ConnectSoft.Extensions.Logging;

    /// <summary>
    /// Default <see cref="IAggregateRootsProcessor"/> implementation 
    /// to process, manage and store AggregateRoots.
    /// </summary>
    public class DefaultAggregateRootsProcessor : IAggregateRootsProcessor
    {
        private readonly ILogger<DefaultAggregateRootsProcessor> logger;
        private readonly TimeProvider dateTimeProvider;
        private readonly IAggregateRootsRepository repository;
        private readonly IUnitOfWork unitOfWork;
        private readonly IValidator<CreateAggregateRootInput> createAggregateRootInputValidator;
        private readonly IValidator<DeleteAggregateRootInput> deleteAggregateRootInputValidator;
#if (UseMassTransit || UseNServiceBus)
        private readonly IEventBus eventBus;
#endif
        private readonly MicroserviceTemplateMetrics meters;

        public DefaultAggregateRootsProcessor(
            ILogger<DefaultAggregateRootsProcessor> logger,
            TimeProvider dateTimeProvider,
            IAggregateRootsRepository repository,
            IUnitOfWork unitOfWork,
#if (UseMassTransit || UseNServiceBus)
            IEventBus eventBus,
#endif
            IValidator<CreateAggregateRootInput> createAggregateRootInputValidator,
            IValidator<DeleteAggregateRootInput> deleteAggregateRootInputValidator,
            MicroserviceTemplateMetrics meters)
        {
            this.logger = logger;
            this.dateTimeProvider = dateTimeProvider;
            this.repository = repository;
            this.unitOfWork = unitOfWork;
#if (UseMassTransit || UseNServiceBus)
            this.eventBus = eventBus;
#endif
            this.createAggregateRootInputValidator = createAggregateRootInputValidator;
            this.deleteAggregateRootInputValidator = deleteAggregateRootInputValidator;
            this.meters = meters;
        }

        /// <inheritdoc/>
        public async Task<IAggregateRoot> CreateAggregateRoot(
            CreateAggregateRootInput input, 
            CancellationToken token = default)
        {
            IAggregateRoot newEntity;
            using (this.logger.BeginScope(
                new Dictionary<string, object>(StringComparer.Ordinal)
                {
                    ["ApplicationFlowName"] = nameof(DefaultAggregateRootsProcessor) + "/" + nameof(this.CreateAggregateRoot),
                }))
            {
                Guid objectId = input.ObjectId;
                var aggregateName = "AggregateRoot";
                string? tenantId = null;
                try
                {
                    // 1. Log operation start
                    this.logger.Here(log => log.LogInformation(
                        message: "Create AggregateRoot for {ObjectId} started...", 
                        objectId));

                    // 2. Validate input
                    await this.createAggregateRootInputValidator
                        .ValidateAndThrowAsync(input, token)
                        .ConfigureAwait(false);

                    // 3. Check business rules (e.g., uniqueness)
                    newEntity = await this.repository.GetByIdAsync(input.ObjectId)
                        .ConfigureAwait(false);

                    if (newEntity != null)
                    {
                        throw new AggregateRootAlreadyExistsException(input.ObjectId);
                    }

                    // 4. Perform domain operation with transaction
                    var sw = Stopwatch.StartNew();
                    newEntity = await this.SaveNewEntity(input, token).ConfigureAwait(false);
                    sw.Stop();

                    // 5. Log success
                    DateTimeOffset utcNow = this.dateTimeProvider.GetUtcNow();
                    this.logger.Here(log => log.LogInformation(
                        message: "AggregateRoot with {ObjectId} created at {UtcNow}...", 
                        objectId, utcNow));

                    // 6. Record metrics
                    this.meters.AddAggregateRoot(
                        sw.Elapsed, 
                        tenantId: tenantId, 
                        aggregate: aggregateName);
                    this.meters.IncreaseTotalAggregateRoots(
                        tenantId: tenantId, 
                        aggregate: aggregateName);

#if (UseMassTransit || UseNServiceBus)
                    // 7. Publish domain event
                    await this.eventBus.PublishEvent<AggregateRootCreatedEvent>(
                        new AggregateRootCreatedEvent()
                        {
                            ObjectId = newEntity.ObjectId,
                        },
                        token).ConfigureAwait(false);
#endif

                    this.logger.Here(log => log.LogInformation(
                        message: "Create AggregateRoot for {ObjectId} successfully completed...", 
                        objectId));
                }
                catch (Exception ex)
                {
                    // 8. Record failure metrics
                    this.meters.AddAggregateRootFailed(
                        tenantId: tenantId, 
                        aggregate: aggregateName);
                    this.logger.Here(log => log.LogError(
                        exception: ex, 
                        message: "Failed to create the AggregateRoot with id : {ObjectId}", 
                        objectId));
                    throw;
                }
            }

            return await Task.FromResult(newEntity).ConfigureAwait(false);
        }

        private async Task<IAggregateRoot> SaveNewEntity(
            CreateAggregateRootInput input, 
            CancellationToken token)
        {
            AggregateRootEntity newEntity = new AggregateRootEntity();

#if UseAuditNet
            using (AuditScope auditScope = await AuditScope.CreateAsync(
                eventType: "Create:AggregateRoot",
                target: () => newEntity,
                cancellationToken: token)
                .ConfigureAwait(false))
            {
                this.unitOfWork.ExecuteTransactional(() =>
                {
                    newEntity.ObjectId = input.ObjectId;
                    this.repository.Insert(newEntity);
                });
            }
#else
            this.unitOfWork.ExecuteTransactional(() =>
            {
                newEntity.ObjectId = input.ObjectId;
                this.repository.Insert(newEntity);
            });
#endif

            return newEntity;
        }

        /// <inheritdoc/>
        public async Task DeleteAggregateRoot(
            DeleteAggregateRootInput input, 
            CancellationToken token = default)
        {
            IAggregateRoot entityToDelete;
            using (this.logger.BeginScope(
                new Dictionary<string, object>(StringComparer.Ordinal)
                {
                    ["ApplicationFlowName"] = nameof(DefaultAggregateRootsProcessor) + "/" + nameof(this.DeleteAggregateRoot),
                }))
            {
                Guid objectId = input.ObjectId;
                var aggregateName = "AggregateRoot";
                string? tenantId = null;

                try
                {
                    this.logger.Here(log => log.LogInformation(
                        message: "Delete AggregateRoot for {ObjectId} started...", 
                        objectId));

                    // 1. Validate input
                    await this.deleteAggregateRootInputValidator
                        .ValidateAndThrowAsync(input, token)
                        .ConfigureAwait(false);

                    // 2. Load entity
                    entityToDelete = await this.repository.GetByIdAsync(input.ObjectId)
                        .ConfigureAwait(false);

                    if (entityToDelete == null)
                    {
                        throw new AggregateRootNotFoundException(input.ObjectId);
                    }

                    // 3. Delete within transaction
                    var sw = Stopwatch.StartNew();
                    await this.DeleteEntity(entityToDelete, token).ConfigureAwait(false);

                    // 4. Record metrics
                    this.meters.DeleteAggregateRoot(
                        sw.Elapsed, 
                        tenantId: tenantId, 
                        aggregate: aggregateName);
                    this.meters.DecreaseTotalAggregateRoots(
                        tenantId: tenantId, 
                        aggregate: aggregateName);

                    this.logger.Here(log => log.LogInformation(
                        message: "Delete AggregateRoot for {ObjectId} successfully completed...", 
                        objectId));
                }
                catch (Exception ex)
                {
                    this.meters.DeleteAggregateRootFailed(
                        tenantId: tenantId, 
                        aggregate: aggregateName);
                    this.logger.Here(log => log.LogError(
                        exception: ex, 
                        message: "Failed to delete the AggregateRoot with id : {ObjectId}", 
                        objectId));
                    throw;
                }
            }

            await Task.CompletedTask.ConfigureAwait(false);
        }

        private async Task DeleteEntity(
            IAggregateRoot entityToDelete, 
            CancellationToken token)
        {
#if UseAuditNet
            using (AuditScope auditScope = await AuditScope.CreateAsync(
                eventType: "Delete:AggregateRoot",
                target: () => entityToDelete,
                cancellationToken: token)
                .ConfigureAwait(false))
            {
                this.unitOfWork.ExecuteTransactional(() =>
                {
                    this.repository.Delete(entityToDelete);
                });
            }
#else
            this.unitOfWork.ExecuteTransactional(() =>
            {
                this.repository.Delete(entityToDelete);
            });
#endif
        }
    }
}

4. Retriever Implementation

The retriever handles query operations:

namespace ConnectSoft.Template.DomainModel.Impl
{
    using System;
    using System.Collections.Generic;
    using System.Diagnostics;
    using System.Threading;
    using System.Threading.Tasks;
    using ConnectSoft.Extensions.Logging;
    using ConnectSoft.Template.EntityModel;
    using ConnectSoft.Template.Metrics;
    using ConnectSoft.Template.PersistenceModel.Repositories;
    using FluentValidation;
    using Microsoft.Extensions.Logging;

    /// <summary>
    /// Default implementation to retrieve AggregateRoots.
    /// </summary>
    public class DefaultAggregateRootsRetriever(
        ILogger<DefaultAggregateRootsRetriever> logger,
        IAggregateRootsRepository repository,
        IValidator<GetAggregateRootDetailsInput> getAggregateRootDetailsInputValidator,
        MicroserviceTemplateMetrics meters)
        : IAggregateRootsRetriever
    {
        private readonly ILogger<DefaultAggregateRootsRetriever> logger = logger;
        private readonly IAggregateRootsRepository repository = repository;
        private readonly IValidator<GetAggregateRootDetailsInput> getAggregateRootDetailsInputValidator = getAggregateRootDetailsInputValidator;
        private readonly MicroserviceTemplateMetrics meters = meters;

        /// <inheritdoc/>
        public async Task<IAggregateRoot> GetAggregateRootDetails(
            GetAggregateRootDetailsInput input, 
            CancellationToken token = default)
        {
            IAggregateRoot requestedEntity;
            using (this.logger.BeginScope(
                new Dictionary<string, object>(StringComparer.Ordinal)
                {
                    ["ApplicationFlowName"] = nameof(DefaultAggregateRootsRetriever) + "/" + nameof(this.GetAggregateRootDetails),
                }))
            {
                Guid objectId = input.ObjectId;
                var aggregateName = "AggregateRoot";
                string? tenantId = null;

                try
                {
                    this.logger.Here(log => log.LogInformation(
                        message: "Get AggregateRoot details for {ObjectId} started...", 
                        objectId));

                    // 1. Validate input
                    await this.getAggregateRootDetailsInputValidator
                        .ValidateAndThrowAsync(input, token)
                        .ConfigureAwait(false);

                    // 2. Retrieve entity
                    var sw = Stopwatch.StartNew();
                    requestedEntity = await this.repository.GetByIdAsync(input.ObjectId)
                        .ConfigureAwait(false);
                    sw.Stop();

                    // 3. Check if found
                    if (requestedEntity == null)
                    {
                        throw new AggregateRootNotFoundException(input.ObjectId);
                    }

                    // 4. Record metrics
                    this.meters.AddAggregateRoot(
                        sw.Elapsed, 
                        tenantId: tenantId, 
                        aggregate: aggregateName);
                    this.logger.Here(log => log.LogInformation(
                        message: "Get AggregateRoot details for {ObjectId} successfully completed...", 
                        objectId));
                }
                catch (Exception ex)
                {
                    this.meters.ReadAggregateRootFailed(
                        tenantId: tenantId, 
                        aggregate: aggregateName);
                    this.logger.Here(log => log.LogError(
                        exception: ex, 
                        message: "Failed to retrieve the AggregateRoot with id : {ObjectId}", 
                        objectId));
                    throw;
                }
            }

            return await Task.FromResult(requestedEntity).ConfigureAwait(false);
        }
    }
}

5. Use Case Implementation

Use cases orchestrate complex business scenarios:

namespace ConnectSoft.Template.DomainModel.Impl
{
    using System;
    using System.Collections.Generic;
    using System.Diagnostics;
    using System.Threading;
    using System.Threading.Tasks;
    using ConnectSoft.Extensions.Logging;
    using ConnectSoft.Template.Metrics;
    using FluentValidation;
    using Microsoft.Extensions.Logging;
    using Microsoft.SemanticKernel;

    /// <summary>
    /// FeatureAUseCaseA's description use case implementation.
    /// </summary>
    public class FeatureAUseCaseAUseCase(
        ILogger<FeatureAUseCaseAUseCase> logger,
        IValidator<FeatureAUseCaseAInput> validator,
        FeatureAMetrics meters,
        Kernel kernel)
        : IFeatureAUseCaseAUseCase
    {
        private readonly ILogger<FeatureAUseCaseAUseCase> logger = logger;
        private readonly IValidator<FeatureAUseCaseAInput> validator = validator;
        private readonly FeatureAMetrics meters = meters;
        private readonly Kernel kernel = kernel;

        /// <inheritdoc/>
        public async Task<FeatureAUseCaseAOutput> ExecuteAsync(
            FeatureAUseCaseAInput input, 
            CancellationToken token)
        {
            FeatureAUseCaseAOutput output = new();

            var stopWatch = Stopwatch.StartNew();
            using (this.logger.BeginScope(
                new Dictionary<string, object>(StringComparer.Ordinal)
                {
                    ["ApplicationFlowName"] = nameof(FeatureAUseCaseAUseCase) + "/" + nameof(this.ExecuteAsync),
                }))
            {
                try
                {
                    this.logger.Here(log => log.LogInformation(
                        message: "FeatureAUseCaseA's description started..."));

                    // 1. Validate input
                    await this.validator.ValidateAndThrowAsync(input, token)
                        .ConfigureAwait(false);

                    // 2. Execute business logic (example with Semantic Kernel)
                    var answer = await this.kernel
                        .InvokePromptAsync("Why is the sky blue in one sentence?")
                        .ConfigureAwait(false);

                    this.logger.LogDebug("Answer: {Answer}", answer);

                    stopWatch.Stop();

                    // 3. Record metrics
                    this.meters.RecordFeatureAUseCaseASuccess(stopWatch.Elapsed);
                    this.meters.IncreaseTotalFeatureA();

                    this.logger.Here(log => log.LogInformation(
                        message: "FeatureAUseCaseA's description successfully completed..."));
                }
                catch (Exception ex)
                {
                    stopWatch.Stop();

                    this.logger.Here(log => log.LogError(
                        exception: ex, 
                        message: "Failed to execute the FeatureAUseCaseA's description use case"));
                    this.meters.RecordFeatureAUseCaseAFailed(
                        duration: stopWatch.Elapsed, 
                        reason: "exception");
                    throw;
                }
            }

            return await Task.FromResult(output).ConfigureAwait(false);
        }
    }
}

6. Input Validators

Validators enforce business rules using FluentValidation:

namespace ConnectSoft.Template.DomainModel.Impl.Validators
{
    using System;
    using FluentValidation;

    /// <summary>
    /// Define a set of validation rules for <see cref="CreateAggregateRootInput"/>.
    /// </summary>
    public class CreateAggregateRootInputValidator 
        : AbstractValidator<CreateAggregateRootInput>
    {
        /// <summary>
        /// Initializes a new instance of the <see cref="CreateAggregateRootInputValidator"/> class.
        /// </summary>
        public CreateAggregateRootInputValidator()
        {
            this.ConfigureValidationRulesForObjectId();
        }

        private void ConfigureValidationRulesForObjectId()
        {
            this.RuleFor(input => input.ObjectId)
                .Must(input =>
                {
                    if (input == Guid.Empty)
                    {
                        throw new ObjectIdRequiredException();
                    }

                    return true;
                });
        }
    }
}

Integration Points

Repository Integration

Processors and retrievers use repository interfaces for persistence:

private readonly IAggregateRootsRepository repository;

// Usage
var entity = await this.repository.GetByIdAsync(input.ObjectId, token);
await this.repository.InsertAsync(newEntity, token);
await this.repository.DeleteAsync(entityToDelete, token);

Unit of Work Integration

Transaction boundaries are managed via Unit of Work:

private readonly IUnitOfWork unitOfWork;

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

Event Bus Integration

Domain events are published after successful operations:

#if (UseMassTransit || UseNServiceBus)
private readonly IEventBus eventBus;

// Usage
await this.eventBus.PublishEvent<AggregateRootCreatedEvent>(
    new AggregateRootCreatedEvent()
    {
        ObjectId = newEntity.ObjectId,
    },
    token);
#endif

Metrics Integration

Custom metrics track domain operations:

private readonly MicroserviceTemplateMetrics meters;

// Usage
this.meters.AddAggregateRoot(
    sw.Elapsed, 
    tenantId: tenantId, 
    aggregate: aggregateName);
this.meters.IncreaseTotalAggregateRoots(
    tenantId: tenantId, 
    aggregate: aggregateName);

Logging Integration

Structured logging with correlation context:

using (this.logger.BeginScope(
    new Dictionary<string, object>(StringComparer.Ordinal)
    {
        ["ApplicationFlowName"] = nameof(DefaultAggregateRootsProcessor) + "/" + nameof(this.CreateAggregateRoot),
    }))
{
    this.logger.Here(log => log.LogInformation(
        message: "Create AggregateRoot for {ObjectId} started...", 
        objectId));
}

Operational Flow

Creating an Aggregate

1. API Controller receives request
2. Maps request to CreateAggregateRootInput
3. Calls IAggregateRootsProcessor.CreateAggregateRoot()
4. Validates input (FluentValidation)
5. Checks business rules (e.g., uniqueness)
6. Executes transaction (Unit of Work)
   ├── Creates entity
   └── Persists via repository
7. Records metrics
8. Publishes domain event (if messaging enabled)
9. Returns created aggregate

Retrieving an Aggregate

1. API Controller receives request
2. Maps request to GetAggregateRootDetailsInput
3. Calls IAggregateRootsRetriever.GetAggregateRootDetails()
4. Validates input
5. Queries repository
6. Checks if found (throws NotFoundException if not)
7. Records metrics
8. Returns aggregate

Domain Exceptions

Domain-specific exceptions express business rule violations:

// Already exists
throw new AggregateRootAlreadyExistsException(input.ObjectId);

// Not found
throw new AggregateRootNotFoundException(input.ObjectId);

// Validation failure
throw new ObjectIdRequiredException();

These exceptions are: - Defined in the DomainModel project - Mapped to HTTP/gRPC status codes in the API layer - Logged appropriately (Warning for business errors, Error for system failures)

Conditional Compilation

The template uses conditional compilation for optional features:

Directive Purpose Example
#if UseMassTransit || UseNServiceBus Messaging framework Event bus integration
#if UseAuditNet Audit logging Audit scope wrapping
#if UseSemanticKernel AI integration Kernel usage in use cases

This allows the same codebase to support different configurations without runtime overhead.

Best Practices

Do's

  1. Validate Early
  2. Validate all inputs before business logic execution
  3. Use FluentValidation for comprehensive validation

  4. Check Business Rules

  5. Enforce uniqueness, existence, and other business invariants
  6. Throw domain-specific exceptions for violations

  7. Use Transactions

  8. Wrap all mutations in Unit of Work transactions
  9. Ensure atomicity of domain operations

  10. Log with Context

  11. Use structured logging with correlation IDs
  12. Include flow names for traceability

  13. Record Metrics

  14. Track operation duration and success/failure rates
  15. Include tenant and aggregate context

  16. Publish Events After Commit

  17. Only publish domain events after successful transaction commits
  18. Ensures consistency between state and events

  19. Separate Concerns

  20. Processors handle mutations
  21. Retrievers handle queries
  22. Use cases handle complex scenarios

Don'ts

  1. Don't Access Infrastructure Directly

    // ❌ BAD - Direct database access
    private readonly DbContext dbContext;
    
    // ✅ GOOD - Repository abstraction
    private readonly IAggregateRootsRepository repository;
    

  2. Don't Skip Validation

    // ❌ BAD - No validation
    public async Task<IAggregateRoot> Create(CreateInput input)
    {
        var entity = new Entity();
        await repository.InsertAsync(entity);
    }
    
    // ✅ GOOD - Validate first
    public async Task<IAggregateRoot> Create(CreateInput input)
    {
        await validator.ValidateAndThrowAsync(input);
        // ... rest of logic
    }
    

  3. Don't Publish Events Before Commit

    // ❌ BAD - Event published before transaction commits
    await eventBus.PublishAsync(@event);
    await unitOfWork.CommitAsync();
    
    // ✅ GOOD - Event published after commit
    await unitOfWork.CommitAsync();
    await eventBus.PublishAsync(@event);
    

  4. Don't Mix Concerns

    // ❌ BAD - Retriever doing mutations
    public async Task<Entity> Get(GetInput input)
    {
        var entity = await repository.GetByIdAsync(input.Id);
        entity.Status = Status.Updated; // Never!
        await repository.UpdateAsync(entity);
        return entity;
    }
    

Configuration

Dependency Injection

Domain services are registered in Program.cs or extension methods:

// Register processors and retrievers
services.AddScoped<IAggregateRootsProcessor, DefaultAggregateRootsProcessor>();
services.AddScoped<IAggregateRootsRetriever, DefaultAggregateRootsRetriever>();
services.AddScoped<IFeatureAUseCaseAUseCase, FeatureAUseCaseAUseCase>();

// Register validators
services.AddFluentValidation();
services.AddValidatorsFromAssemblyContaining<CreateAggregateRootInputValidator>();

// Register dependencies
services.AddScoped<IAggregateRootsRepository, AggregateRootsRepository>();
services.AddScoped<IUnitOfWork, NHibernateUnitOfWork>();
#if (UseMassTransit || UseNServiceBus)
services.AddSingleton<IEventBus, MassTransitAdapter>();
#endif

Testing

Unit Testing Processors

[TestMethod]
public async Task Create_WhenAlreadyExists_ThrowsException()
{
    // Arrange
    var repository = new Mock<IAggregateRootsRepository>();
    repository.Setup(r => r.GetByIdAsync(It.IsAny<Guid>()))
        .ReturnsAsync(new AggregateRootEntity());

    var processor = new DefaultAggregateRootsProcessor(
        logger, timeProvider, repository.Object, unitOfWork, 
        eventBus, validator, deleteValidator, metrics);

    // Act & Assert
    await Assert.ThrowsExceptionAsync<AggregateRootAlreadyExistsException>(
        () => processor.CreateAggregateRoot(new CreateAggregateRootInput 
        { 
            ObjectId = Guid.NewGuid() 
        }));
}

Integration Testing

Integration tests verify end-to-end flows:

[TestMethod]
public async Task Create_WhenValid_SuccessfullyCreatesAndPublishesEvent()
{
    // Arrange
    var input = new CreateAggregateRootInput 
    { 
        ObjectId = Guid.NewGuid() 
    };

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

    // Assert
    Assert.IsNotNull(result);
    Assert.AreEqual(input.ObjectId, result.ObjectId);
    // Verify event was published
    // Verify metrics were recorded
}

Summary

The Domain Model in the ConnectSoft ConnectSoft Templates provides:

  • Application Services: Processors, Retrievers, and Use Cases for business operations
  • Input/Output Models: Transient objects for data transfer
  • Validation: FluentValidation for input validation
  • Transaction Management: Unit of Work pattern for atomicity
  • Event Publishing: Integration with messaging frameworks
  • Observability: Structured logging, metrics, and distributed tracing
  • Clean Architecture: Clear separation between domain logic and infrastructure
  • Testability: All components are easily testable through dependency injection

By following these patterns, the domain model layer ensures:

  • Business rules are enforced — Through validation and business logic checks
  • Transactions are atomic — Via Unit of Work pattern
  • Operations are observable — Through logging, metrics, and tracing
  • Events are reliable — Published only after successful commits
  • Code is maintainable — Clear separation of concerns and responsibilities

The domain model is the orchestration layer that coordinates between APIs, domain entities, and infrastructure, ensuring that business operations are performed correctly, consistently, and observably.