Your complete roadmap for building production-grade backend systems using Clean Architecture (Onion/Hexagonal). Learn what evaluators look for, common pitfalls, and strategies to impress senior developers during code review.
Understanding the round format helps you manage scope and time effectively. This isn't a test—it's a collaborative signal of how you approach real-world problems.
You have 2 weeks to build a small-to-medium backend project. Typical scope:
After submission, you'll meet with a senior developer for ~1 hour:
Clean Architecture (Onion Model) separates concerns into independent, testable layers. Each layer has a single responsibility and depends only on inner layers.
Pure business logic with zero infrastructure dependencies. No HTTP, no databases—just entities and interfaces.
// Domain/Entities/User.cs - Pure business entity public class User { public int Id { get; set; } public string Email { get; set; } public string FullName { get; set; } public bool IsActive { get; set; } public class CreateCommand { public string Email { get; set; } public string FullName { get; set; } } } // Domain/Interfaces/IUserRepository.cs - Contracts (no impl) public interface IUserRepository { Task<User> GetByIdAsync(int id); Task<User> GetByEmailAsync(string email); Task<int> CreateAsync(User user); }
Orchestrates domain entities to execute business use cases. Contains DTOs, validators, and application services.
// Application/UseCases/CreateUserHandler.cs public class CreateUserHandler { private readonly IUserRepository _userRepository; private readonly IPasswordHasher _passwordHasher; private readonly IValidator<User.CreateCommand> _validator; public CreateUserHandler(IUserRepository userRepository, IPasswordHasher hasher, IValidator<User.CreateCommand> validator) { _userRepository = userRepository; _passwordHasher = hasher; _validator = validator; } public async Task<int> HandleAsync(User.CreateCommand command) { // Validate input var validationResult = await _validator.ValidateAsync(command); if (!validationResult.IsValid) { throw new ValidationException(validationResult.Errors); } // Check if email already exists var existing = await _userRepository.GetByEmailAsync(command.Email); if (existing != null) { throw new DomainException("Email already registered"); } // Create and persist var user = new User { Email = command.Email, FullName = command.FullName, IsActive = true }; return await _userRepository.CreateAsync(user); } } // Application/DTOs/UserResponseDto.cs public class UserResponseDto { public int Id { get; set; } public string Email { get; set; } public string FullName { get; set; } }
Implements repository interfaces, database access, and external service integrations. Uses EF Core, HTTP clients, etc.
// Infrastructure/Data/EfUserRepository.cs public class EfUserRepository : IUserRepository { private readonly ApplicationDbContext _dbContext; public EfUserRepository(ApplicationDbContext dbContext) { _dbContext = dbContext; } public async Task<User> GetByIdAsync(int id) { return await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == id); } public async Task<User> GetByEmailAsync(string email) { return await _dbContext.Users.FirstOrDefaultAsync(u => u.Email == email); } public async Task<int> CreateAsync(User user) { _dbContext.Users.Add(user); await _dbContext.SaveChangesAsync(); return user.Id; } } // Infrastructure/Data/ApplicationDbContext.cs public class ApplicationDbContext : DbContext { public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) {} public DbSet<User> Users { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly); } }
HTTP controllers, middleware, and Swagger docs. Depends on all inner layers.
// API/Controllers/UsersController.cs [ApiController] [Route("api/users")] public class UsersController : ControllerBase { private readonly CreateUserHandler _createUserHandler; private readonly ILogger<UsersController> _logger; public UsersController(CreateUserHandler createUserHandler, ILogger<UsersController> logger) { _createUserHandler = createUserHandler; _logger = logger; } [HttpPost] public async Task<IActionResult> CreateUserAsync([FromBody] User.CreateCommand command) { try { _logger.LogInformation("Creating user: {Email}", command.Email); var userId = await _createUserHandler.HandleAsync(command); return CreatedAtAction(nameof(GetUserAsync), new { id = userId }, new { id = userId }); } catch (ValidationException ex) { _logger.LogWarning("Validation failed: {Message}", ex.Message); return BadRequest(new { errors = ex.Errors }); } catch (DomainException ex) { _logger.LogWarning("Domain error: {Message}", ex.Message); return Conflict(new { message = ex.Message }); } } [HttpGet("{id:int}")] public async Task<IActionResult> GetUserAsync(int id) { // Implementation return Ok(); } }
These features distinguish production-grade code from student projects. Evaluators expect all of them.
Configure all dependencies in one place. Makes testing and scaling straightforward.
// Program.cs - DI Registration var builder = WebApplication.CreateBuilder(args); // Database builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")) ); // Repositories builder.Services.AddScoped<IUserRepository, EfUserRepository>(); builder.Services.AddScoped<IProductRepository, EfProductRepository>(); // Use Cases builder.Services.AddScoped<CreateUserHandler>(); builder.Services.AddScoped<GetUserHandler>(); // Services builder.Services.AddScoped<IPasswordHasher, BcryptPasswordHasher>(); builder.Services.AddScoped<IEmailService, SmtpEmailService>(); // Validators builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly); // Controllers & Logging builder.Services.AddControllers(); builder.Services.AddLogging(config => config.AddConsole().AddDebug() ); var app = builder.Build(); app.UseHttpsRedirection(); app.MapControllers(); await app.RunAsync();
Abstracts data access, enables testing, and maintains consistency across entities.
// Domain/Interfaces/IUnitOfWork.cs public interface IUnitOfWork : IDisposable { IUserRepository Users { get; } IProductRepository Products { get; } IOrderRepository Orders { get; } Task<int> SaveChangesAsync(); } // Infrastructure/Data/UnitOfWork.cs public class UnitOfWork : IUnitOfWork { private readonly ApplicationDbContext _dbContext; private IUserRepository _userRepository; private IProductRepository _productRepository; public UnitOfWork(ApplicationDbContext dbContext) { _dbContext = dbContext; } public IUserRepository Users => _userRepository ??= new EfUserRepository(_dbContext); public IProductRepository Products => _productRepository ??= new EfProductRepository(_dbContext); public async Task<int> SaveChangesAsync() => await _dbContext.SaveChangesAsync(); public void Dispose() => _dbContext?.Dispose(); }
Test business logic in isolation. Aim for >70% code coverage.
// Tests/Unit/CreateUserHandlerTests.cs public class CreateUserHandlerTests { private readonly Mock<IUserRepository> _userRepoMock; private readonly Mock<IPasswordHasher> _hasherMock; private readonly CreateUserHandler _handler; public CreateUserHandlerTests() { _userRepoMock = new Mock<IUserRepository>(); _hasherMock = new Mock<IPasswordHasher>(); _handler = new CreateUserHandler(_userRepoMock.Object, _hasherMock.Object); } [Fact] public async Task Handle_WithValidCommand_ReturnsUserId() { // Arrange var command = new User.CreateCommand { Email = "john@example.com", FullName = "John Doe" }; _userRepoMock.Setup(r => r.GetByEmailAsync(command.Email)).ReturnsAsync((User)null); _userRepoMock.Setup(r => r.CreateAsync(It.IsAny<User>())).ReturnsAsync(1); // Act var result = await _handler.HandleAsync(command); // Assert Assert.Equal(1, result); _userRepoMock.Verify(r => r.CreateAsync(It.IsAny<User>()), Times.Once); } [Fact] public async Task Handle_WithExistingEmail_ThrowsDomainException() { // Arrange var command = new User.CreateCommand { Email = "existing@example.com" }; _userRepoMock.Setup(r => r.GetByEmailAsync(command.Email)) .ReturnsAsync(new User { Id = 1, Email = command.Email }); // Act & Assert await Assert.ThrowsAsync<DomainException>(() => _handler.HandleAsync(command)); } }
Test API endpoints end-to-end with a real (test) database.
// Tests/Integration/UsersControllerTests.cs public class UsersControllerTests { private readonly WebApplicationFactory<Program> _factory; private readonly HttpClient _client; public UsersControllerTests() { _factory = new WebApplicationFactory<Program>() .WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // Replace real DB with in-memory var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>)); services.Remove(descriptor); services.AddDbContext<ApplicationDbContext>(options => options.UseInMemoryDatabase("TestDb") ); }); }); _client = _factory.CreateClient(); } [Fact] public async Task CreateUser_WithValidData_Returns201() { // Arrange var payload = new { Email = "test@example.com", FullName = "Test User" }; var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); // Act var response = await _client.PostAsync("/api/users", content); // Assert Assert.Equal(HttpStatusCode.Created, response.StatusCode); } }
Centralize exception handling to return consistent HTTP responses.
// API/Middleware/ErrorHandlingMiddleware.cs public class ErrorHandlingMiddleware { private readonly RequestDelegate _next; private readonly ILogger<ErrorHandlingMiddleware> _logger; public ErrorHandlingMiddleware(RequestDelegate next, ILogger<ErrorHandlingMiddleware> logger) { _next = next; _logger = logger; } public async Task InvokeAsync(HttpContext context) { try { await _next(context); } catch (Exception ex) { _logger.LogError(ex, "Unhandled exception"); context.Response.ContentType = "application/json"; var response = ex switch { ValidationException ve => new { statusCode = 400, message = "Validation failed", errors = ve.Errors }, DomainException de => new { statusCode = 409, message = de.Message }, _ => new { statusCode = 500, message = "Internal server error" } }; context.Response.StatusCode = (int)(dynamic)response.GetType().GetProperty("statusCode").GetValue(response); await context.Response.WriteAsJsonAsync(response); } } }
Instrument code for debugging and monitoring. Use structured logging.
// Infrastructure/Services/UserService.cs - With Logging public class UserService { private readonly IUserRepository _repository; private readonly ILogger<UserService> _logger; public UserService(IUserRepository repository, ILogger<UserService> logger) { _repository = repository; _logger = logger; } public async Task<User> GetUserAsync(int id) { _logger.LogInformation("Fetching user: {UserId}", id); var user = await _repository.GetByIdAsync(id); if (user == null) { _logger.LogWarning("User not found: {UserId}", id); throw new NotFoundException("User not found"); } _logger.LogInformation("User retrieved successfully: {UserId} {Email}", user.Id, user.Email); return user; } }
Validate data at the application layer before business logic.
// Application/Validators/CreateUserValidator.cs public class CreateUserValidator : AbstractValidator<User.CreateCommand> { public CreateUserValidator() { RuleFor(x => x.Email) .NotEmpty().WithMessage("Email is required") .EmailAddress().WithMessage("Email must be valid") .MaximumLength(255).WithMessage("Email is too long"); RuleFor(x => x.FullName) .NotEmpty().WithMessage("Full name is required") .Length(2, 100).WithMessage("Full name must be 2-100 chars"); } }
Auto-generate and document your API.
// Program.cs - Swagger Setup builder.Services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1", Description = "Production API with Clean Architecture" }); }); var app = builder.Build(); app.UseSwagger(); app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "v1")); // Controller - With XML Documentation [HttpPost] [ProducesResponseType(typeof(UserResponseDto), 201)] [ProducesResponseType(400)] /// <summary> /// Creates a new user account</summary> /// <param name="command">User creation data</param> public async Task<IActionResult> CreateAsync([FromBody] User.CreateCommand command) { // Implementation }
Containerize your application for easy deployment.
# Dockerfile FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build WORKDIR /app COPY . . RUN dotnet restore RUN dotnet publish -c Release -o out FROM mcr.microsoft.com/dotnet/aspnet:7.0 WORKDIR /app COPY --from=build /app/out . EXPOSE 8080 ENTRYPOINT ["dotnet", "API.dll"]
# docker-compose.yml version: '3.8' services: api: build: . ports: - "8080:8080" environment: - ASPNETCORE_ENVIRONMENT=Development - ConnectionStrings__DefaultConnection=Server=db;Database=myapp;User Id=sa;Password=YourPassword123! depends_on: - db db: image: mcr.microsoft.com/mssql/server:2022-latest environment: ACCEPT_EULA: "Y" SA_PASSWORD: YourPassword123! ports: - "1433:1433"
Before you submit, verify every item. This is what evaluators assess in code review.
A real end-to-end example showing proper architecture, testing, and deployment setup.
// Domain/Entities/Product.cs public class Product { public int Id { get; set; } public string Name { get; set; } public string Description { get; set; } public decimal Price { get; set; } public int StockQuantity { get; set; } public DateTime CreatedAt { get; set; } // Business method: Reduce stock on purchase public void ReduceStock(int quantity) { if (quantity > StockQuantity) { throw new DomainException("Insufficient stock"); } StockQuantity -= quantity; } public class CreateCommand { public string Name { get; set; } public string Description { get; set; } public decimal Price { get; set; } public int StockQuantity { get; set; } } } // Domain/Interfaces/IProductRepository.cs public interface IProductRepository { Task<Product> GetByIdAsync(int id); Task<List<Product>> ListAsync(int skip = 0, int take = 10); Task<int> CreateAsync(Product product); Task<bool> UpdateAsync(Product product); }
// Application/UseCases/CreateProductHandler.cs public class CreateProductHandler { private readonly IProductRepository _repository; private readonly IValidator<Product.CreateCommand> _validator; private readonly ILogger<CreateProductHandler> _logger; public CreateProductHandler( IProductRepository repository, IValidator<Product.CreateCommand> validator, ILogger<CreateProductHandler> logger) { _repository = repository; _validator = validator; _logger = logger; } public async Task<int> HandleAsync(Product.CreateCommand command) { // Validate var result = await _validator.ValidateAsync(command); if (!result.IsValid) { _logger.LogWarning("Product validation failed"); throw new ValidationException(result.Errors); } // Create entity var product = new Product { Name = command.Name, Description = command.Description, Price = command.Price, StockQuantity = command.StockQuantity, CreatedAt = DateTime.UtcNow }; // Persist var id = await _repository.CreateAsync(product); _logger.LogInformation("Product created: {ProductId}", id); return id; } } // Application/Validators/CreateProductValidator.cs public class CreateProductValidator : AbstractValidator<Product.CreateCommand> { public CreateProductValidator() { RuleFor(x => x.Name) .NotEmpty().WithMessage("Product name required") .MaximumLength(200); RuleFor(x => x.Price) .GreaterThan(0).WithMessage("Price must be positive"); RuleFor(x => x.StockQuantity) .GreaterThanOrEqualTo(0).WithMessage("Stock cannot be negative"); } } // Application/DTOs/ProductResponseDto.cs public class ProductResponseDto { public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public int StockQuantity { get; set; } }
// API/Controllers/ProductsController.cs [ApiController] [Route("api/products")] public class ProductsController : ControllerBase { private readonly CreateProductHandler _createHandler; private readonly IProductRepository _repository; private readonly ILogger<ProductsController> _logger; public ProductsController( CreateProductHandler createHandler, IProductRepository repository, ILogger<ProductsController> logger) { _createHandler = createHandler; _repository = repository; _logger = logger; } [HttpPost] [ProducesResponseType(typeof(ProductResponseDto), 201)] [ProducesResponseType(400)] /// <summary>Create a new product</summary> public async Task<IActionResult> CreateAsync([FromBody] Product.CreateCommand command) { try { var productId = await _createHandler.HandleAsync(command); var product = await _repository.GetByIdAsync(productId); var dto = new ProductResponseDto { Id = product.Id, Name = product.Name, Price = product.Price, StockQuantity = product.StockQuantity }; return CreatedAtAction(nameof(GetAsync), new { id = productId }, dto); } catch (ValidationException ex) { return BadRequest(new { errors = ex.Errors }); } } [HttpGet("{id:int}")] [ProducesResponseType(typeof(ProductResponseDto), 200)] [ProducesResponseType(404)] /// <summary>Get product by ID</summary> public async Task<IActionResult> GetAsync(int id) { var product = await _repository.GetByIdAsync(id); if (product == null) { _logger.LogWarning("Product not found: {ProductId}", id); return NotFound(); } return Ok(new ProductResponseDto { Id = product.Id, Name = product.Name, Price = product.Price, StockQuantity = product.StockQuantity }); } }
// Tests/Integration/ProductsControllerTests.cs public class ProductsControllerTests : IAsyncLifetime { private readonly WebApplicationFactory<Program> _factory; private HttpClient _client; public ProductsControllerTests() { _factory = new WebApplicationFactory<Program>() .WithWebHostBuilder(builder => { builder.ConfigureServices(services => { var descriptor = services.SingleOrDefault( d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>)); services.Remove(descriptor); services.AddDbContext<ApplicationDbContext>( options => options.UseInMemoryDatabase("TestDb")); }); }); } public async Task InitializeAsync() { _client = _factory.CreateClient(); } public async Task DisposeAsync() { _client?.Dispose(); _factory?.Dispose(); } [Fact] public async Task Create_WithValidData_Returns201() { var payload = new { Name = "Laptop", Description = "High-end laptop", Price = 999.99, StockQuantity = 10 }; var content = new StringContent( JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); var response = await _client.PostAsync("/api/products", content); Assert.Equal(HttpStatusCode.Created, response.StatusCode); Assert.NotNull(response.Headers.Location); } [Fact] public async Task Create_WithInvalidPrice_Returns400() { var payload = new { Name = "Invalid", Price = -10, StockQuantity = 5 }; var content = new StringContent( JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); var response = await _client.PostAsync("/api/products", content); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } }
Evaluators will ask these questions. Have concise, confident answers ready.
Good Answer: "I used Clean Architecture (Onion Model) because it separates concerns into independent layers. The domain layer contains pure business logic, the application layer orchestrates use cases, and the infrastructure layer handles persistence. This separation makes testing easier—I can test handlers in isolation by mocking repositories—and it makes the codebase more maintainable and scalable."
Good Answer: "First, I'd identify the bottleneck. If it's database reads, I'd add caching (Redis) at the repository layer. If it's writes, I'd implement CQRS to separate read and write models. For horizontal scaling, I'd containerize with Docker and orchestrate with Kubernetes. I'd also add logging and monitoring to track performance."
Good Answer: "I'd evaluate whether the full 4-layer architecture was necessary—maybe a simpler layered model would suffice. I might also consider event sourcing if the domain involves complex state changes. And I'd invest more upfront in integration tests for critical paths rather than discovering issues in production."
Good Answer: "I define custom exception types (DomainException, ValidationException) to signal specific error conditions. At the API layer, middleware catches exceptions and returns consistent HTTP responses with appropriate status codes. I log all errors with context (RequestId, UserId) for debugging. Clients receive user-friendly messages, never stack traces."
Good Answer: "I follow the testing pyramid: mostly unit tests for business logic (handlers, services), fewer integration tests for API endpoints, and a few end-to-end tests for critical user workflows. Unit tests use Moq to isolate dependencies. Integration tests use WebApplicationFactory with an in-memory database. I aim for >70% coverage on critical paths."
Good Answer: "I design migrations to be reversible and backward-compatible. I avoid deleting columns immediately—instead, I deprecate them first. I test migrations locally and in a staging environment before production. I also keep database schema changes separate from code changes when possible."
Good Answer: "I use FluentValidation to validate input at the application layer boundary. Validators check format, length, and business rules (e.g., email uniqueness). If validation fails, handlers throw ValidationException, which middleware converts to a 400 response with detailed error messages. This keeps validation logic testable and separate from controllers."
Good Answer: "In Program.cs, I register all dependencies with the IServiceCollection. Repositories are registered as Scoped (one per HTTP request). Handlers and services are Scoped as well. External dependencies (password hashers, email services) are abstracted behind interfaces so they can be swapped for testing. This makes the container a single source of truth for wiring."
Good Answer: "I use Entity Framework Core with parameterized queries—never string concatenation. EF translates LINQ queries to parameterized SQL. I also validate all user input before passing to the database layer. Additionally, I run static analysis tools like SonarAnalyzer to catch security issues early."
Good Answer: "I chose simplicity over premature optimization. For example, I used synchronous validation instead of async validators because the overhead wasn't justified. I also decided to use in-memory caching initially rather than Redis because the system's traffic was predictable. These decisions are easy to revisit as requirements change."
Good Answer: "I'd add distributed tracing (Application Insights or Jaeger) to track requests across services if we scale horizontally. I'd also implement API versioning to maintain backward compatibility. And I'd spend more time on performance testing—profiling the most common queries and ensuring they use indexes efficiently."
Evaluators see these patterns frequently. Avoid them to stand out.
Problem: Submitting code with zero unit or integration tests signals that you haven't validated your code. Tests are non-negotiable in production systems.
Fix: Write at minimum 10-15 unit tests covering the happy path and edge cases. Add 3-5 integration tests for critical endpoints.
Problem: A single UserService with 50+ methods handling users, validation, emails, and logging. Violates Single Responsibility.
Fix: Break into smaller classes: UserHandler, PasswordValidator, EmailService. Each has one reason to change.
// ❌ WRONG - SQL Injection vulnerability var query = $"SELECT * FROM Users WHERE Email = '{email}'"; var user = dbContext.Users.FromSqlInterpolated(query); // ✅ CORRECT - Parameterized query var user = await dbContext.Users.FirstOrDefaultAsync(u => u.Email == email);
Problem: Using .Result or .Wait() on async calls blocks threads and causes deadlocks under load.
// ❌ WRONG - Blocks thread var user = _repository.GetByIdAsync(id).Result; // ✅ CORRECT - Async all the way var user = await _repository.GetByIdAsync(id);
Problem: Exceptions bubble up unhandled, returning 500 with stack traces. Clients get no useful information.
Fix: Add ErrorHandlingMiddleware to catch exceptions and return appropriate status codes with meaningful messages.
Problem: When something breaks in production, there's no audit trail. You can't debug issues.
Fix: Use ILogger throughout. Log at entry/exit of important methods, and log all errors with context.
// ❌ WRONG - Tightly coupled public class UserHandler { private EfUserRepository _repository = new EfUserRepository(); } // ✅ CORRECT - Loosely coupled via interface public class UserHandler { private readonly IUserRepository _repository; public UserHandler(IUserRepository repository) => _repository = repository; }
Problem: Accepting any input (negative prices, huge strings) and trusting the database to reject it.
Fix: Validate at the application layer using FluentValidation. Fail fast with clear error messages.
Problem: Always returning 200 or 500, even when errors occur. Clients can't tell if a request succeeded.
Fix: Use correct status codes: 201 for Created, 400 for Bad Request, 404 for Not Found, 409 for Conflict, 500 for Internal Server Error.
// ❌ WRONG - Magic number if (password.Length < 8) throw new Exception("Too short"); // ✅ CORRECT - Named constant const int MinPasswordLength = 8; if (password.Length < MinPasswordLength) { throw new ValidationException("Password too short"); }
Problem: No README, no Swagger docs, no commit messages. Evaluators can't understand the project architecture.
Fix: Include a README with setup instructions, enable Swagger UI, write meaningful commit messages, add XML comments to public methods.
Problem: Entire project committed in one massive commit, or commits like "fix", "update", "oops". No way to track design decisions.
Fix: Commit frequently with descriptive messages: "Add user repository with GetByIdAsync and CreateAsync methods".
Use this checklist the night before submission. Don't submit until all items are green.