Solution Architecture · How it works

2 min read
Mid-level6 min read
Rapid overview

How 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 DomainCore shared 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:

ElementDescriptionExample
Bounded ContextClear ownership boundaryOrders, Pricing, Identity
Aggregate RootConsistency boundary, entry pointQuestionerTemplate
EntityHas identity, mutableOrder, User
Value ObjectNo identity, immutableMoney, TenantId
Domain EventSomething that happenedOrderPlaced
SpecificationEncapsulated query logicInactiveTemplatesSpec

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);
}

See also