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¶
- Validate Early
- Validate all inputs before business logic execution
-
Use FluentValidation for comprehensive validation
-
Check Business Rules
- Enforce uniqueness, existence, and other business invariants
-
Throw domain-specific exceptions for violations
-
Use Transactions
- Wrap all mutations in Unit of Work transactions
-
Ensure atomicity of domain operations
-
Log with Context
- Use structured logging with correlation IDs
-
Include flow names for traceability
-
Record Metrics
- Track operation duration and success/failure rates
-
Include tenant and aggregate context
-
Publish Events After Commit
- Only publish domain events after successful transaction commits
-
Ensures consistency between state and events
-
Separate Concerns
- Processors handle mutations
- Retrievers handle queries
- Use cases handle complex scenarios
Don'ts¶
-
Don't Access Infrastructure Directly
-
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 } -
Don't Publish Events Before Commit
-
Don't Mix Concerns
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.