Solution Architecture

8 min read
Rapid overview

Solution Architecture - Structuring Code and Use Cases

Organize code by business boundaries so change stays local, testable, and easy to reason about.


Quick Overview

  • Clean Architecture: Layered design with dependencies pointing inward; stable core, replaceable outer layers.
  • DDD (Domain-Driven Design): Model the business with bounded contexts, aggregates, and ubiquitous language.
  • Vertical Slices: Organize by feature/use case instead of technical layers to minimize cross-cutting changes.
  • CQRS: Separate read and write models for different optimization strategies.
  • You can combine them: DDD inside Clean Architecture, vertical slices inside the Application layer, CQRS for handlers.

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.


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

Technology Stack Integration

LayerTechnologyPurpose
WebFastEndpointsMinimal API endpoints with validation
ApplicationMediatRCQRS command/query handling
ValidationFluentValidationRequest validation
DomainDomainCore (shared)Base entities, domain events
InfrastructureEF CoreORM, migrations
DatabasePostgreSQLPer-service database
CachingRedisDistributed cache
AuthKeycloakOAuth2/OIDC identity provider

Shared NuGet Packages Pattern

NuGetPackages/
+-- DomainCore/              # Base entities, domain events
+-- MultiTenancy.EFCore/     # Tenant isolation infrastructure
+-- Identity.Keycloak/       # Keycloak integration
+-- Security.Claims/         # Claims extraction helpers
+-- ServiceDefaults/         # Health checks, logging defaults

Choosing the Right Approach

ScenarioRecommendation
Long-lived backend with integrationsClean Architecture
Complex domain, frequent rule changesDDD + Clean Architecture
Fast-moving feature teamsVertical Slices
High-read workloadsCQRS with read replicas
Multi-tenant SaaSTenant entity pattern + global filters
Desktop/mobile UIMVVM

Why It Matters for Interviews

  • You can justify how you structure a solution and why it scales with team size.
  • You can explain trade-offs (complexity vs flexibility, speed vs rigor).
  • You can show how architecture supports testability and maintainability.
  • You can discuss real implementation patterns (not just theory).

Common Pitfalls

  • Treating Clean Architecture as a strict template instead of a guide.
  • Building a massive shared kernel that becomes a dependency sink.
  • Splitting into micro-layers too early without proven complexity.
  • Not using Result pattern, relying on exceptions for control flow.
  • Missing specifications, putting query logic everywhere.
  • Forgetting domain events for cross-aggregate communication.
  • Skipping validation at the application layer.

Quick Reference

  • Clean Architecture: Dependencies flow inward; domain has zero dependencies.
  • DDD: Bounded contexts + aggregates + ubiquitous language + domain events.
  • CQRS: Separate command and query models for different optimization strategies.
  • Vertical Slice: Organize by feature, not technical layer.
  • Specification: Encapsulate reusable query logic.
  • Result Pattern: Return success/failure state, don't throw for expected cases.

Sample Interview Q&A

  • Q: Can Clean Architecture and DDD coexist?
  • A: Yes. DDD provides the modeling approach inside the Domain layer, while Clean Architecture provides the dependency and layering rules around it.
  • Q: Why choose vertical slices over layered folders?
  • A: Vertical slices keep related code together, reduce navigation overhead, and keep changes localized to a feature. Deleting a feature is deleting a folder.
  • Q: How do you implement multi-tenancy in Clean Architecture?
  • A: Create a BaseTenantEntity with TenantId, inject ICurrentTenantService into DbContext, and apply global query filters. Domain layer stays clean; tenant isolation is infrastructure concern.
  • Q: When would you use the Result pattern over exceptions?
  • A: For expected failures (validation, not found, business rules). Exceptions are for unexpected errors. Result pattern makes error handling explicit and easier to test.
  • Q: What is the purpose of domain events?
  • A: Cross-aggregate communication without coupling. When an Order is placed, it raises OrderPlaced event. Other aggregates or services can react without Order knowing about them.
  • Q: How do you test Clean Architecture applications?
  • A: Unit tests for domain logic (no mocks needed). Handler tests with mocked repositories. Integration tests with real database. Functional tests through API endpoints.