Solution Architecture · How it works
2 min readHow it works
Organize code by business boundaries so change stays local, testable, and easy to reason about.
Detailed Explanation
Clean Architecture (Layered, Dependencies Inward)
What it is: A layered architecture where the Domain has zero dependencies. Application orchestrates use cases. Infrastructure and UI sit on the outside.
+-------------------------------+
| Presentation / Web | FastEndpoints, Controllers, DTOs
+-------------------------------+
| Application | Use cases, CQRS handlers, Mappers
+-------------------------------+
| Domain | Entities, Value Objects, Aggregates
+-------------------------------+
| Infrastructure | DbContext, Repositories, External APIs
+-------------------------------+
Project Structure (Real-World Example):
OnlineMenuService/
+-- src/
| +-- OnlineMenu.Web/ # Presentation Layer
| | +-- Endpoints/ # FastEndpoints
| | +-- Program.cs # DI setup, middleware
| |
| +-- OnlineMenu.UseCases/ # Application Layer
| | +-- TenantMenus/
| | | +-- Create/
| | | | +-- CreateTenantMenusCommand.cs
| | | | +-- CreateTenantMenusHandler.cs
| | | +-- List/
| | | +-- ListTenantMenusQuery.cs
| | | +-- ListTenantMenusHandler.cs
| | +-- DTOs/
| | +-- Mappers/
| |
| +-- OnlineMenu.Core/ # Domain Layer
| | +-- Entities/
| | | +-- TenantMenus.cs # Aggregate Root
| | +-- Interfaces/
| | +-- Specifications/
| |
| +-- OnlineMenu.Infrastructure/ # Infrastructure Layer
| +-- Data/
| | +-- AppDbContext.cs
| | +-- EfRepository.cs
| +-- Services/
+-- tests/
+-- OnlineMenu.UnitTests/
+-- OnlineMenu.IntegrationTests/
+-- OnlineMenu.FunctionalTests/
Key Principles:
- Dependency Rule: Inner layers know nothing about outer layers
- Domain has zero dependencies (only
DomainCoreshared package) - Infrastructure implements interfaces defined in Core
- Application orchestrates but doesn't contain business rules
Use it when: You have long-lived business rules and want infrastructure swaps (SQL, Kafka, S3 bridges) without touching domain logic.
Trade-offs: More projects and abstractions; can be overkill for tiny apps.
Domain-Driven Design (DDD)
What it is: A modeling approach that focuses on the business language and boundaries.
Core Elements:
| Element | Description | Example |
|---|---|---|
| Bounded Context | Clear ownership boundary | Orders, Pricing, Identity |
| Aggregate Root | Consistency boundary, entry point | QuestionerTemplate |
| Entity | Has identity, mutable | Order, User |
| Value Object | No identity, immutable | Money, TenantId |
| Domain Event | Something that happened | OrderPlaced |
| Specification | Encapsulated query logic | InactiveTemplatesSpec |
Base Entity Pattern:
public abstract class BaseEntity : HasDomainEventsBase
{
public int Id { get; set; }
public Guid ExternalId { get; private set; } = Guid.NewGuid();
public DateTime CreatedDate { get; private set; } = DateTime.UtcNow;
public DateTime LastUpdatedDate { get; private set; } = DateTime.UtcNow;
public void UpdateTimestamp() => LastUpdatedDate = DateTime.UtcNow;
}
// Aggregate Root marker
public class QuestionerTemplate : BaseQuestioner, IAggregateRoot
{
// Aggregate enforces invariants
public void AddQuestion(Question question)
{
if (_questions.Count >= MaxQuestions)
throw new DomainException("Maximum questions reached");
_questions.Add(question);
}
}
Domain Events:
public abstract class HasDomainEventsBase
{
private readonly List<DomainEventBase> _domainEvents = new();
public IReadOnlyCollection<DomainEventBase> DomainEvents => _domainEvents.AsReadOnly();
protected void RegisterDomainEvent(DomainEventBase domainEvent)
=> _domainEvents.Add(domainEvent);
public void ClearDomainEvents() => _domainEvents.Clear();
}
Use it when: The domain is complex and business rules change often (trading rules, risk limits, compliance).
Trade-offs: Requires strong collaboration and discipline; adds modeling overhead.
CQRS with MediatR
What it is: Command Query Responsibility Segregation - separate models for reads and writes.
Pattern:
// Command (Write) - returns Result
public sealed record CreateTenantMenusCommand(
string Name,
string Description) : IRequest<Result<Guid>>;
public sealed class CreateTenantMenusHandler
: IRequestHandler<CreateTenantMenusCommand, Result<Guid>>
{
private readonly IBaseRepository<TenantMenus> _repository;
public CreateTenantMenusHandler(IBaseRepository<TenantMenus> repository)
{
_repository = repository;
}
public async Task<Result<Guid>> Handle(
CreateTenantMenusCommand request,
CancellationToken ct)
{
var entity = TenantMenus.Create(request.Name, request.Description);
await _repository.AddAsync(entity, ct);
return Result.Success(entity.ExternalId);
}
}
// Query (Read) - returns DTO
public sealed record ListTenantMenusQuery : IRequest<Result<IEnumerable<TenantMenusDto>>>;
public sealed class ListTenantMenusHandler
: IRequestHandler<ListTenantMenusQuery, Result<IEnumerable<TenantMenusDto>>>
{
private readonly IReadRepository<TenantMenus> _repository;
public async Task<Result<IEnumerable<TenantMenusDto>>> Handle(
ListTenantMenusQuery request,
CancellationToken ct)
{
var entities = await _repository.ListAsync(ct);
return Result.Success(entities.Select(TenantMenusMapper.ToDto));
}
}
Benefits:
- Optimized read models (can skip ORM for reads)
- Clear separation of concerns
- Easier to scale reads and writes independently
- Natural fit with event sourcing
Vertical Slice Architecture
What it is: Organize code by feature/use case so each slice includes its handler, validation, and DTOs.
Application/
Orders/
PlaceOrder/
PlaceOrderCommand.cs
PlaceOrderHandler.cs
PlaceOrderValidator.cs
PlaceOrderDto.cs
GetOrder/
GetOrderQuery.cs
GetOrderHandler.cs
Benefits:
- Change stays in one folder (low cognitive load)
- Tests and handlers stay close to their use case
- Avoids over-abstraction by feature
- Easy to delete entire features
Trade-offs: Can duplicate infrastructure concerns if you skip shared abstractions.
MVVM (Model-View-ViewModel)
What it is: UI pattern for desktop/mobile apps (WPF/MAUI) where the View binds to a ViewModel that holds state and commands.
View <-> ViewModel <-> Model
XAML C# state Domain/DTOs
Use it when: You need testable UI logic and clean separation in WPF/MAUI apps; rich-client UIs with heavy data binding.
Trade-offs: Extra plumbing for bindings and property change notifications; overkill for simple screens with minimal state.
Multi-Tenant Entity Pattern
What it is: Base entity that includes tenant context for automatic isolation.
public abstract class BaseTenantEntity : BaseEntity
{
public Guid TenantId { get; private set; }
public Guid UserId { get; private set; }
protected BaseTenantEntity(Guid tenantId, Guid userId)
{
TenantId = tenantId;
UserId = userId;
}
}
// DbContext with automatic tenant filtering
public class AppDbContext : DbContext
{
private readonly ICurrentTenantService _tenantService;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Apply to all tenant entities
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (typeof(BaseTenantEntity).IsAssignableFrom(entityType.ClrType))
{
SetTenantFilter(modelBuilder, entityType.ClrType);
}
}
}
private void SetTenantFilter(ModelBuilder modelBuilder, Type entityType)
{
var method = typeof(AppDbContext)
.GetMethod(nameof(SetTenantFilterGeneric), BindingFlags.NonPublic | BindingFlags.Instance)!
.MakeGenericMethod(entityType);
method.Invoke(this, new object[] { modelBuilder });
}
private void SetTenantFilterGeneric<TEntity>(ModelBuilder modelBuilder)
where TEntity : BaseTenantEntity
{
modelBuilder.Entity<TEntity>().HasQueryFilter(e =>
_tenantService.TenantId == null || // SuperUser bypass
e.TenantId == _tenantService.TenantId);
}
}
Specification Pattern
What it is: Encapsulate query logic in reusable specification objects.
// Using Ardalis.Specification
public class InactiveQuestionerTemplatesSpec : Specification<QuestionerTemplate>
{
public InactiveQuestionerTemplatesSpec()
{
Query
.Where(t => !t.IsActive)
.OrderByDescending(t => t.CreatedDate)
.Take(100);
}
}
// Usage
var inactiveTemplates = await _repository.ListAsync(new InactiveQuestionerTemplatesSpec());
Benefits:
- Reusable query logic
- Testable in isolation
- Composable specifications
- Keeps repository clean
Result Pattern
What it is: Return operation results with success/failure state instead of throwing exceptions.
// Using Ardalis.Result
public async Task<Result<Guid>> Handle(CreateOrderCommand request, CancellationToken ct)
{
// Validation failure
if (string.IsNullOrEmpty(request.Symbol))
return Result<Guid>.Invalid(new ValidationError("Symbol is required"));
// Not found
var account = await _accounts.GetByIdAsync(request.AccountId, ct);
if (account is null)
return Result<Guid>.NotFound($"Account {request.AccountId} not found");
// Business rule failure
if (!account.HasSufficientFunds(request.Amount))
return Result<Guid>.Error("Insufficient funds");
// Success
var order = Order.Create(request.Symbol, request.Amount);
await _orders.AddAsync(order, ct);
return Result.Success(order.Id);
}