Solution Architecture
8 min read- Solution Architecture - Structuring Code and Use Cases
- Quick Overview
- Detailed Explanation
- Clean Architecture (Layered, Dependencies Inward)
- Domain-Driven Design (DDD)
- CQRS with MediatR
- Vertical Slice Architecture
- Multi-Tenant Entity Pattern
- Specification Pattern
- Result Pattern
- Technology Stack Integration
- Recommended Stack
- Shared NuGet Packages Pattern
- Choosing the Right Approach
- Why It Matters for Interviews
- Common Pitfalls
- Quick Reference
- Sample Interview Q&A
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
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.
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
Recommended Stack
| Layer | Technology | Purpose |
|---|---|---|
| Web | FastEndpoints | Minimal API endpoints with validation |
| Application | MediatR | CQRS command/query handling |
| Validation | FluentValidation | Request validation |
| Domain | DomainCore (shared) | Base entities, domain events |
| Infrastructure | EF Core | ORM, migrations |
| Database | PostgreSQL | Per-service database |
| Caching | Redis | Distributed cache |
| Auth | Keycloak | OAuth2/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
| Scenario | Recommendation |
|---|---|
| Long-lived backend with integrations | Clean Architecture |
| Complex domain, frequent rule changes | DDD + Clean Architecture |
| Fast-moving feature teams | Vertical Slices |
| High-read workloads | CQRS with read replicas |
| Multi-tenant SaaS | Tenant entity pattern + global filters |
| Desktop/mobile UI | MVVM |
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
BaseTenantEntitywith TenantId, injectICurrentTenantServiceinto 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
OrderPlacedevent. 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.