19 KiB
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
# 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
# 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
# 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
// 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
// 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
// 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
// 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
// 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:
// 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
- Create YourApp.Domain project
- Move domain entities
- Move domain interfaces (IUserRepository, etc.)
- Update references
Step 2: Extract Infrastructure
- Create YourApp.Infrastructure project
- Move DbContext
- Move repository implementations
- Move external service clients
- Update references
Step 3: Extract CQRS
- Create YourApp.CQRS project
- Move commands, queries, handlers
- Move validators
- Update references
Step 4: Keep Only Presentation in Api
- Keep Program.cs
- Keep configuration files
- Keep middleware
- 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 - DI patterns for handlers
- Tutorials: Modular Solution - Step-by-step guide
See Also
- CQRS Pattern - Understanding CQRS
- Best Practices: Testing - Testing strategies
- Best Practices: Deployment - Production deployment