dotnet-cqrs/docs/architecture/modular-solution-structure.md

703 lines
19 KiB
Markdown

# Modular Solution Structure
Best practices for organizing your Svrnty.CQRS application into clean, maintainable layers.
## Overview
For production applications, organize your code into separate projects with clear responsibilities and dependencies. This approach provides:
-**Separation of concerns** - Each project has a single responsibility
-**Dependency control** - Clear, one-way dependencies
-**Testability** - Easy to test each layer in isolation
-**Reusability** - Share domain logic across multiple APIs
-**Team scalability** - Different teams can own different projects
## Recommended Structure
```
YourSolution/
├── src/
│ ├── YourApp.Api/ # HTTP/gRPC endpoints (entry point)
│ ├── YourApp.CQRS/ # Commands, queries, handlers
│ ├── YourApp.Domain/ # Domain models, business logic
│ ├── YourApp.Infrastructure/ # Data access, external services
│ └── YourApp.Contracts/ # Shared DTOs (optional)
├── tests/
│ ├── YourApp.CQRS.Tests/ # Unit tests for handlers
│ ├── YourApp.Domain.Tests/ # Unit tests for domain logic
│ └── YourApp.Api.Tests/ # Integration tests
└── YourSolution.sln
```
## Project Responsibilities
### 1. YourApp.Api (Presentation Layer)
**Purpose:** HTTP/gRPC endpoints, configuration, startup logic
**Contains:**
- Program.cs
- appsettings.json
- Proto files (for gRPC)
- Middleware configuration
- Service registration
- Authentication/authorization setup
**Dependencies:**
```
YourApp.Api
→ YourApp.CQRS
→ YourApp.Infrastructure
→ Svrnty.CQRS.MinimalApi (or .Grpc)
```
**Example structure:**
```
YourApp.Api/
├── Program.cs
├── appsettings.json
├── appsettings.Development.json
├── Protos/
│ └── services.proto
└── Extensions/
├── ServiceRegistrationExtensions.cs
└── ConfigurationExtensions.cs
```
### 2. YourApp.CQRS (Application Layer)
**Purpose:** Commands, queries, handlers, validators, application logic
**Contains:**
- Command definitions
- Query definitions
- Command handlers
- Query handlers
- FluentValidation validators
- Application services
- DTOs (or reference Contracts project)
**Dependencies:**
```
YourApp.CQRS
→ YourApp.Domain
→ YourApp.Contracts (optional)
→ Svrnty.CQRS.Abstractions
→ FluentValidation
```
**Example structure:**
```
YourApp.CQRS/
├── Commands/
│ ├── Users/
│ │ ├── CreateUserCommand.cs
│ │ ├── CreateUserCommandHandler.cs
│ │ └── CreateUserCommandValidator.cs
│ └── Orders/
│ ├── PlaceOrderCommand.cs
│ ├── PlaceOrderCommandHandler.cs
│ └── PlaceOrderCommandValidator.cs
├── Queries/
│ ├── Users/
│ │ ├── GetUserQuery.cs
│ │ ├── GetUserQueryHandler.cs
│ │ └── GetUserQueryValidator.cs
│ └── Orders/
│ ├── GetOrderQuery.cs
│ └── GetOrderQueryHandler.cs
├── DTOs/
│ ├── UserDto.cs
│ └── OrderDto.cs
└── Services/
└── EmailService.cs
```
### 3. YourApp.Domain (Domain Layer)
**Purpose:** Business logic, domain entities, domain events, business rules
**Contains:**
- Domain entities (aggregates, value objects)
- Domain events
- Domain services
- Business rules
- Interfaces for repositories (abstractions)
**Dependencies:**
```
YourApp.Domain
→ (No dependencies - pure domain logic)
```
**Example structure:**
```
YourApp.Domain/
├── Entities/
│ ├── User.cs
│ ├── Order.cs
│ └── OrderLine.cs
├── ValueObjects/
│ ├── Email.cs
│ └── Address.cs
├── Events/
│ ├── UserCreatedEvent.cs
│ └── OrderPlacedEvent.cs
├── Services/
│ └── OrderPricingService.cs
└── Repositories/
├── IUserRepository.cs
└── IOrderRepository.cs
```
### 4. YourApp.Infrastructure (Infrastructure Layer)
**Purpose:** Data access, external services, cross-cutting concerns
**Contains:**
- EF Core DbContext
- Repository implementations
- External API clients
- File storage
- Email services
- Caching
- Logging configuration
**Dependencies:**
```
YourApp.Infrastructure
→ YourApp.Domain
→ Entity Framework Core
→ External SDK packages
```
**Example structure:**
```
YourApp.Infrastructure/
├── Data/
│ ├── ApplicationDbContext.cs
│ ├── Migrations/
│ └── Configurations/
│ ├── UserConfiguration.cs
│ └── OrderConfiguration.cs
├── Repositories/
│ ├── UserRepository.cs
│ └── OrderRepository.cs
├── ExternalServices/
│ ├── SendGridEmailService.cs
│ └── StripePaymentService.cs
└── Caching/
└── RedisCacheService.cs
```
### 5. YourApp.Contracts (Shared DTOs - Optional)
**Purpose:** Shared data transfer objects used across layers
**Contains:**
- Request DTOs
- Response DTOs
- Shared view models
**Dependencies:**
```
YourApp.Contracts
→ (No dependencies)
```
**Example structure:**
```
YourApp.Contracts/
├── Users/
│ ├── UserDto.cs
│ └── CreateUserRequest.cs
└── Orders/
├── OrderDto.cs
└── PlaceOrderRequest.cs
```
## Dependency Flow
```
┌─────────────┐
│ YourApp.Api│
└──────┬──────┘
┌──────────────┐ ┌────────────────────┐
│YourApp.CQRS │─────▶│ YourApp.Contracts │
└──────┬───────┘ └────────────────────┘
┌──────────────┐
│YourApp.Domain│◀──────┐
└──────────────┘ │
┌────────┴──────────┐
│YourApp.Infrastructure│
└───────────────────┘
```
**Key principle:** Dependencies flow downward and inward. Domain has no dependencies.
## Complete Example
### Create the Solution
```bash
# Create solution
dotnet new sln -n YourApp
# Create projects
dotnet new webapi -n YourApp.Api -o src/YourApp.Api
dotnet new classlib -n YourApp.CQRS -o src/YourApp.CQRS
dotnet new classlib -n YourApp.Domain -o src/YourApp.Domain
dotnet new classlib -n YourApp.Infrastructure -o src/YourApp.Infrastructure
dotnet new classlib -n YourApp.Contracts -o src/YourApp.Contracts
# Create test projects
dotnet new xunit -n YourApp.CQRS.Tests -o tests/YourApp.CQRS.Tests
dotnet new xunit -n YourApp.Domain.Tests -o tests/YourApp.Domain.Tests
dotnet new xunit -n YourApp.Api.Tests -o tests/YourApp.Api.Tests
# Add projects to solution
dotnet sln add src/YourApp.Api/YourApp.Api.csproj
dotnet sln add src/YourApp.CQRS/YourApp.CQRS.csproj
dotnet sln add src/YourApp.Domain/YourApp.Domain.csproj
dotnet sln add src/YourApp.Infrastructure/YourApp.Infrastructure.csproj
dotnet sln add src/YourApp.Contracts/YourApp.Contracts.csproj
dotnet sln add tests/YourApp.CQRS.Tests/YourApp.CQRS.Tests.csproj
dotnet sln add tests/YourApp.Domain.Tests/YourApp.Domain.Tests.csproj
dotnet sln add tests/YourApp.Api.Tests/YourApp.Api.Tests.csproj
```
### Add Project References
```bash
# Api references
cd src/YourApp.Api
dotnet add reference ../YourApp.CQRS/YourApp.CQRS.csproj
dotnet add reference ../YourApp.Infrastructure/YourApp.Infrastructure.csproj
# CQRS references
cd ../YourApp.CQRS
dotnet add reference ../YourApp.Domain/YourApp.Domain.csproj
dotnet add reference ../YourApp.Contracts/YourApp.Contracts.csproj
# Infrastructure references
cd ../YourApp.Infrastructure
dotnet add reference ../YourApp.Domain/YourApp.Domain.csproj
# Test references
cd ../../tests/YourApp.CQRS.Tests
dotnet add reference ../../src/YourApp.CQRS/YourApp.CQRS.csproj
cd ../YourApp.Domain.Tests
dotnet add reference ../../src/YourApp.Domain/YourApp.Domain.csproj
cd ../YourApp.Api.Tests
dotnet add reference ../../src/YourApp.Api/YourApp.Api.csproj
```
### Install NuGet Packages
```bash
# Api
cd ../../src/YourApp.Api
dotnet add package Svrnty.CQRS.MinimalApi
dotnet add package Svrnty.CQRS.FluentValidation
# CQRS
cd ../YourApp.CQRS
dotnet add package Svrnty.CQRS.Abstractions
dotnet add package FluentValidation
# Domain
cd ../YourApp.Domain
# No packages needed (pure domain logic)
# Infrastructure
cd ../YourApp.Infrastructure
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design
```
## Example Implementation
### Domain Layer
```csharp
// YourApp.Domain/Entities/User.cs
namespace YourApp.Domain.Entities;
public class User
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public static User Create(string name, string email)
{
// Business rules
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Name is required", nameof(name));
if (string.IsNullOrWhiteSpace(email))
throw new ArgumentException("Email is required", nameof(email));
return new User { Name = name, Email = email };
}
}
// YourApp.Domain/Repositories/IUserRepository.cs
namespace YourApp.Domain.Repositories;
public interface IUserRepository
{
Task<User?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<User?> GetByEmailAsync(string email, CancellationToken cancellationToken = default);
Task<int> AddAsync(User user, CancellationToken cancellationToken = default);
Task UpdateAsync(User user, CancellationToken cancellationToken = default);
}
```
### Contracts Layer
```csharp
// YourApp.Contracts/Users/UserDto.cs
namespace YourApp.Contracts.Users;
public record UserDto
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public string Email { get; init; } = string.Empty;
public DateTime CreatedAt { get; init; }
}
```
### CQRS Layer
```csharp
// YourApp.CQRS/Commands/Users/CreateUserCommand.cs
using YourApp.Domain.Entities;
using YourApp.Domain.Repositories;
using Svrnty.CQRS.Abstractions;
using FluentValidation;
namespace YourApp.CQRS.Commands.Users;
public record CreateUserCommand
{
public string Name { get; init; } = string.Empty;
public string Email { get; init; } = string.Empty;
}
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
private readonly IUserRepository _userRepository;
public CreateUserCommandHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<int> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken)
{
// Use domain logic
var user = User.Create(command.Name, command.Email);
return await _userRepository.AddAsync(user, cancellationToken);
}
}
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
private readonly IUserRepository _userRepository;
public CreateUserCommandValidator(IUserRepository userRepository)
{
_userRepository = userRepository;
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
.MustAsync(BeUniqueEmail).WithMessage("Email already exists");
}
private async Task<bool> BeUniqueEmail(string email, CancellationToken cancellationToken)
{
var exists = await _userRepository.GetByEmailAsync(email, cancellationToken);
return exists == null;
}
}
// YourApp.CQRS/Queries/Users/GetUserQuery.cs
using YourApp.Contracts.Users;
using YourApp.Domain.Repositories;
using Svrnty.CQRS.Abstractions;
namespace YourApp.CQRS.Queries.Users;
public record GetUserQuery
{
public int UserId { get; init; }
}
public class GetUserQueryHandler : IQueryHandler<GetUserQuery, UserDto>
{
private readonly IUserRepository _userRepository;
public GetUserQueryHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<UserDto> HandleAsync(GetUserQuery query, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(query.UserId, cancellationToken);
if (user == null)
throw new KeyNotFoundException($"User {query.UserId} not found");
return new UserDto
{
Id = user.Id,
Name = user.Name,
Email = user.Email,
CreatedAt = user.CreatedAt
};
}
}
```
### Infrastructure Layer
```csharp
// YourApp.Infrastructure/Data/ApplicationDbContext.cs
using Microsoft.EntityFrameworkCore;
using YourApp.Domain.Entities;
namespace YourApp.Infrastructure.Data;
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
public DbSet<User> Users => Set<User>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
}
}
// YourApp.Infrastructure/Repositories/UserRepository.cs
using Microsoft.EntityFrameworkCore;
using YourApp.Domain.Entities;
using YourApp.Domain.Repositories;
using YourApp.Infrastructure.Data;
namespace YourApp.Infrastructure.Repositories;
public class UserRepository : IUserRepository
{
private readonly ApplicationDbContext _context;
public UserRepository(ApplicationDbContext context)
{
_context = context;
}
public async Task<User?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
}
public async Task<User?> GetByEmailAsync(string email, CancellationToken cancellationToken = default)
{
return await _context.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Email == email, cancellationToken);
}
public async Task<int> AddAsync(User user, CancellationToken cancellationToken = default)
{
_context.Users.Add(user);
await _context.SaveChangesAsync(cancellationToken);
return user.Id;
}
public async Task UpdateAsync(User user, CancellationToken cancellationToken = default)
{
_context.Users.Update(user);
await _context.SaveChangesAsync(cancellationToken);
}
}
```
### Api Layer
```csharp
// YourApp.Api/Program.cs
using Microsoft.EntityFrameworkCore;
using YourApp.CQRS.Commands.Users;
using YourApp.CQRS.Queries.Users;
using YourApp.Domain.Repositories;
using YourApp.Infrastructure.Data;
using YourApp.Infrastructure.Repositories;
var builder = WebApplication.CreateBuilder(args);
// Infrastructure
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<IUserRepository, UserRepository>();
// CQRS
builder.Services.AddSvrntyCQRS();
builder.Services.AddDefaultCommandDiscovery();
builder.Services.AddDefaultQueryDiscovery();
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler, CreateUserCommandValidator>();
builder.Services.AddQuery<GetUserQuery, UserDto, GetUserQueryHandler>();
var app = builder.Build();
// Map endpoints
app.UseSvrntyCqrs();
app.Run();
```
## Benefits of Modular Structure
### 1. Clear Responsibilities
Each project has one job:
- Api: Expose endpoints
- CQRS: Application logic
- Domain: Business rules
- Infrastructure: Technical concerns
### 2. Testability
Test each layer in isolation:
```csharp
// YourApp.Domain.Tests/Entities/UserTests.cs
[Fact]
public void Create_WithValidData_ReturnsUser()
{
var user = User.Create("Alice", "alice@example.com");
Assert.Equal("Alice", user.Name);
Assert.Equal("alice@example.com", user.Email);
}
// YourApp.CQRS.Tests/Commands/CreateUserCommandHandlerTests.cs
[Fact]
public async Task HandleAsync_WithValidCommand_CreatesUser()
{
var mockRepo = new Mock<IUserRepository>();
mockRepo.Setup(r => r.AddAsync(It.IsAny<User>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(123);
var handler = new CreateUserCommandHandler(mockRepo.Object);
var command = new CreateUserCommand { Name = "Alice", Email = "alice@example.com" };
var result = await handler.HandleAsync(command, CancellationToken.None);
Assert.Equal(123, result);
}
```
### 3. Reusability
Share domain logic across multiple APIs:
```
YourApp.PublicApi ──┐
├──▶ YourApp.CQRS ──▶ YourApp.Domain
YourApp.AdminApi ──┘
```
### 4. Team Scalability
Different teams can own different projects:
- Team A: Domain & CQRS
- Team B: Infrastructure
- Team C: API
## Migration from Single Project
If you started with a single project, migrate gradually:
### Step 1: Extract Domain
1. Create YourApp.Domain project
2. Move domain entities
3. Move domain interfaces (IUserRepository, etc.)
4. Update references
### Step 2: Extract Infrastructure
1. Create YourApp.Infrastructure project
2. Move DbContext
3. Move repository implementations
4. Move external service clients
5. Update references
### Step 3: Extract CQRS
1. Create YourApp.CQRS project
2. Move commands, queries, handlers
3. Move validators
4. Update references
### Step 4: Keep Only Presentation in Api
1. Keep Program.cs
2. Keep configuration files
3. Keep middleware
4. Delete everything else (moved to other projects)
## Best Practices
### ✅ DO
- Keep domain layer pure (no dependencies)
- Use interfaces in domain, implementations in infrastructure
- Put DTOs in CQRS or Contracts layer
- Keep Api layer thin (configuration only)
- Use dependency injection
- Follow one-way dependencies (downward/inward)
### ❌ DON'T
- Don't reference Infrastructure from Domain
- Don't put business logic in Api layer
- Don't reference Api from other projects
- Don't create circular dependencies
- Don't mix presentation and business logic
## What's Next?
- **[Dependency Injection](dependency-injection.md)** - DI patterns for handlers
- **[Tutorials: Modular Solution](../tutorials/modular-solution/README.md)** - Step-by-step guide
## See Also
- [CQRS Pattern](cqrs-pattern.md) - Understanding CQRS
- [Best Practices: Testing](../best-practices/testing.md) - Testing strategies
- [Best Practices: Deployment](../best-practices/deployment.md) - Production deployment