Pragmatic Clean Architecture for .NET API: Part 1 - Building a Freelancer Task Automation API

What This Tutorial Covers
Core principles of Clean Architecture and its benefits
Building a unique Freelancer Task Automation API
Implementing entities, use cases, and repositories
Setting up ASP.NET Core with proper error handling
Writing unit tests for business logic
Prerequisites
.NET 8 SDK
Visual Studio 2022 or VS Code
Basic knowledge of C# and ASP.NET Core
SQL Server (LocalDB is sufficient for development)
Let's start with a quick note on our API: the Freelancer Task Automation API helps freelancers manage tasks by creating projects, tracking hours, and generating invoices automatically, streamlining workflows for SaaS-like productivity tools.
- 💡Note on Code Organization: For brevity in this blog post, I'm showing multiple exception classes in a single code block. In a production application each class should have its own file. This follows the Single Responsibility Principle and makes the codebase more maintainable.
The Clean Architecture layers, visualizing how tasks, invoices, and business logic come together in this production-ready .NET API.
Clean Architecture ensures .NET APIs are maintainable, testable, and independent of frameworks. In this two-part series, we'll build a unique API for freelancer task automation, managing tasks, generating invoices, and tracking project hours. Part 1 focuses on building the API with Clean Architecture; Part 2 covers Azure deployment and production enhancements.
Why Clean Architecture?
Clean Architecture, introduced by Robert C. Martin (Uncle Bob), organizes code to achieve separation of concerns and dependency inversion. The key principle is that inner layers (business logic) should not depend on outer layers (frameworks, databases).
The architecture organizes code into layers:
Entities: Core business objects (e.g., tasks, invoices) - the "what" of your domain
Use Cases/Application: Business logic (e.g., invoice generation) - the "how" of your business rules
Interfaces/Adapters: Connectors to external systems (e.g., repositories) - the contracts
Frameworks/Drivers: External tools (e.g., ASP.NET Core) - the implementation details
Benefits:
Maintainability: Clear boundaries simplify updates
Testability: Logic is testable without dependencies
Flexibility: Swap databases or services easily
Business Focus: Domain logic remains pure and framework-agnostic
Scenario: Freelancer Task Automation
The API supports:
Creating tasks (title, hourly rate, client email)
Generating invoices based on tracked hours
Tracking hours for tasks with validation (e.g., non-negative hours)
Preventing double invoicing for the same period
Email notifications for generated invoices
Project Structure
Step 1: Define Entities and Domain Exceptions (Core Layer)
Entities are independent C# classes. Note: We're using FreelancerTask
instead of Task
to avoid confusion with System.Threading.Tasks.Task
.
// Freelancer.Core/Exceptions/DomainExceptions.cs namespace Freelancer.Core.Exceptions { public class DomainException : Exception { public DomainException(string message) : base(message) { } } public class TaskNotFoundException : DomainException { public TaskNotFoundException(Guid taskId) : base($"Task with ID {taskId} not found.") { } } public class TaskAlreadyInvoicedException : DomainException { public TaskAlreadyInvoicedException(Guid taskId) : base($"Task {taskId} has already been invoiced for the current period.") { } } public class InsufficientHoursException : DomainException { public InsufficientHoursException(decimal hours) : base($"Cannot generate invoice with {hours} hours. Minimum billable hours required.") { } } }
// Freelancer.Core/Entities/FreelancerTask.cs
namespace Freelancer.Core.Entities
{
public class FreelancerTask
{
public Guid Id { get; private set; }
public string Title { get; private set; }
public decimal HourlyRate { get; private set; }
public string ClientEmail { get; private set; }
public decimal TotalHours { get; private set; }
public DateTime? LastInvoicedDate { get; private set; }
public DateTime CreatedDate { get; private set; }
public FreelancerTask(string title, decimal hourlyRate, string clientEmail)
{
Id = Guid.NewGuid();
Title = title ?? throw new ArgumentNullException(nameof(title));
HourlyRate = hourlyRate >= 0 ? hourlyRate : throw new ArgumentException("Hourly rate cannot be negative.");
ClientEmail = IsValidEmail(clientEmail) ? clientEmail : throw new ArgumentException("Invalid email.");
TotalHours = 0;
CreatedDate = DateTime.UtcNow;
}
public void AddHours(decimal hours)
{
if (hours < 0) throw new ArgumentException("Hours cannot be negative.");
TotalHours += hours;
}
public void MarkAsInvoiced()
{
LastInvoicedDate = DateTime.UtcNow;
}
public bool CanGenerateInvoice()
{
return TotalHours > 0 &&
(LastInvoicedDate == null || LastInvoicedDate.Value.Date < DateTime.UtcNow.Date);
}
private bool IsValidEmail(string email)
{
try { var addr = new System.Net.Mail.MailAddress(email); return true; }
catch { return false; }
}
}
}
// Freelancer.Core/Entities/Invoice.cs
namespace Freelancer.Core.Entities
{
public class Invoice
{
public Guid Id { get; private set; }
public Guid TaskId { get; private set; }
public decimal Amount { get; private set; }
public DateTime IssuedDate { get; private set; }
public InvoiceStatus Status { get; private set; }
public Invoice(Guid taskId, decimal amount)
{
Id = Guid.NewGuid();
TaskId = taskId;
Amount = amount >= 0 ? amount : throw new ArgumentException("Amount cannot be negative.");
IssuedDate = DateTime.UtcNow;
Status = InvoiceStatus.Pending;
}
public void MarkAsSent()
{
Status = InvoiceStatus.Sent;
}
}
public enum InvoiceStatus
{
Pending,
Sent,
Paid,
Overdue
}
}
Step 2: Define Use Cases (Application Layer)
The application layer orchestrates logic.
// Freelancer.Core/Interfaces/ITaskService.cs
namespace Freelancer.Core.Interfaces
{
public interface ITaskService
{
Task<FreelancerTask> CreateTaskAsync(string title, decimal hourlyRate, string clientEmail);
Task<FreelancerTask> GetTaskByIdAsync(Guid id);
Task AddTaskHoursAsync(Guid id, decimal hours);
Task<Invoice> GenerateInvoiceAsync(Guid taskId);
Task<IEnumerable<FreelancerTask>> GetTasksAsync(int pageNumber, int pageSize);
}
}
// Freelancer.Application/Services/TaskService.cs
using Freelancer.Core.Exceptions;
namespace Freelancer.Application.Services
{
public class TaskService : ITaskService
{
private readonly ITaskRepository _taskRepository;
private readonly IInvoiceRepository _invoiceRepository;
private const decimal MinimumBillableHours = 0.5m;
public TaskService(ITaskRepository taskRepository, IInvoiceRepository invoiceRepository)
{
_taskRepository = taskRepository ?? throw new ArgumentNullException(nameof(taskRepository));
_invoiceRepository = invoiceRepository ?? throw new ArgumentNullException(nameof(invoiceRepository));
}
public async Task<FreelancerTask> CreateTaskAsync(string title, decimal hourlyRate, string clientEmail)
{
var task = new FreelancerTask(title, hourlyRate, clientEmail);
await _taskRepository.AddAsync(task);
return task;
}
public async Task<FreelancerTask> GetTaskByIdAsync(Guid id)
{
var task = await _taskRepository.GetByIdAsync(id);
return task ?? throw new TaskNotFoundException(id);
}
public async Task AddTaskHoursAsync(Guid id, decimal hours)
{
var task = await _taskRepository.GetByIdAsync(id);
if (task == null) throw new TaskNotFoundException(id);
task.AddHours(hours);
await _taskRepository.UpdateAsync(task);
}
public async Task<Invoice> GenerateInvoiceAsync(Guid taskId)
{
var task = await _taskRepository.GetByIdAsync(taskId);
if (task == null) throw new TaskNotFoundException(taskId);
if (!task.CanGenerateInvoice())
throw new TaskAlreadyInvoicedException(taskId);
if (task.TotalHours < MinimumBillableHours)
throw new InsufficientHoursException(task.TotalHours);
var amount = task.TotalHours * task.HourlyRate;
var invoice = new Invoice(taskId, amount);
await _invoiceRepository.AddAsync(invoice);
task.MarkAsInvoiced();
await _taskRepository.UpdateAsync(task);
return invoice;
}
public async Task<IEnumerable<FreelancerTask>> GetTasksAsync(int pageNumber, int pageSize)
{
return await _taskRepository.GetPagedAsync(pageNumber, pageSize);
}
}
}
Step 3: Implement Repositories (Infrastructure Layer)
Repositories handle data access with EF Core.
// Freelancer.Core/Interfaces/ITaskRepository.cs
namespace Freelancer.Core.Interfaces
{
public interface ITaskRepository
{
Task AddAsync(FreelancerTask task);
Task<FreelancerTask> GetByIdAsync(Guid id);
Task UpdateAsync(FreelancerTask task);
Task<IEnumerable<FreelancerTask>> GetPagedAsync(int pageNumber, int pageSize);
}
}
// Freelancer.Core/Interfaces/IInvoiceRepository.cs
namespace Freelancer.Core.Interfaces
{
public interface IInvoiceRepository
{
Task AddAsync(Invoice invoice);
Task<Invoice> GetByIdAsync(Guid id);
}
}
// Freelancer.Infrastructure/Data/AppDbContext.cs
using Freelancer.Core.Entities;
using Microsoft.EntityFrameworkCore;
namespace Freelancer.Infrastructure.Data
{
public class AppDbContext : DbContext
{
public DbSet<FreelancerTask> Tasks { get; set; }
public DbSet<Invoice> Invoices { get; set; }
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Configure indexes for performance
modelBuilder.Entity<FreelancerTask>()
.HasIndex(t => t.ClientEmail);
modelBuilder.Entity<Invoice>()
.HasIndex(i => i.TaskId);
modelBuilder.Entity<Invoice>()
.HasIndex(i => i.IssuedDate);
}
}
}
// Freelancer.Infrastructure/Repositories/TaskRepository.cs
namespace Freelancer.Infrastructure.Repositories
{
public class TaskRepository : ITaskRepository
{
private readonly AppDbContext _context;
public TaskRepository(AppDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task AddAsync(FreelancerTask task)
{
await _context.Tasks.AddAsync(task);
await _context.SaveChangesAsync();
}
public async Task<FreelancerTask> GetByIdAsync(Guid id)
{
return await _context.Tasks.FindAsync(id);
}
public async Task UpdateAsync(FreelancerTask task)
{
_context.Tasks.Update(task);
await _context.SaveChangesAsync();
}
public async Task<IEnumerable<FreelancerTask>> GetPagedAsync(int pageNumber, int pageSize)
{
return await _context.Tasks
.OrderByDescending(t => t.CreatedDate)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
}
}
Step 4: Set Up the API (Framework Layer)
Expose endpoints with error handling, validation, and API documentation.
// Freelancer.API/Middleware/ExceptionMiddleware.cs
using Microsoft.AspNetCore.Http;
using System.Net;
using System.Text.Json;
using Freelancer.Core.Exceptions;
namespace Freelancer.API.Middleware
{
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionMiddleware> _logger;
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occurred processing the request");
await HandleExceptionAsync(context, ex);
}
}
private static async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.ContentType = "application/json";
var (statusCode, message) = exception switch
{
TaskNotFoundException => (HttpStatusCode.NotFound, exception.Message),
TaskAlreadyInvoicedException => (HttpStatusCode.Conflict, exception.Message),
InsufficientHoursException => (HttpStatusCode.BadRequest, exception.Message),
DomainException => (HttpStatusCode.BadRequest, exception.Message),
ArgumentException => (HttpStatusCode.BadRequest, exception.Message),
_ => (HttpStatusCode.InternalServerError, "An error occurred while processing your request.")
};
context.Response.StatusCode = (int)statusCode;
var response = new
{
error = message,
status = statusCode.ToString(),
timestamp = DateTime.UtcNow
};
await context.Response.WriteAsync(JsonSerializer.Serialize(response));
}
}
}
// Freelancer.API/Controllers/TasksController.cs
using Freelancer.Core.Entities;
using Freelancer.Core.Interfaces;
using Microsoft.AspNetCore.Mvc;
using FluentValidation;
namespace Freelancer.API.Controllers
{
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class TasksController : ControllerBase
{
private readonly ITaskService _taskService;
private readonly IValidator<CreateTaskRequest> _createValidator;
public TasksController(ITaskService taskService, IValidator<CreateTaskRequest> createValidator)
{
_taskService = taskService ?? throw new ArgumentNullException(nameof(taskService));
_createValidator = createValidator ?? throw new ArgumentNullException(nameof(createValidator));
}
/// <summary>
/// Creates a new freelancer task
/// </summary>
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateTask([FromBody] CreateTaskRequest request)
{
var validationResult = await _createValidator.ValidateAsync(request);
if (!validationResult.IsValid)
{
return BadRequest(validationResult.Errors.Select(e => new { e.PropertyName, e.ErrorMessage }));
}
var task = await _taskService.CreateTaskAsync(request.Title, request.HourlyRate, request.ClientEmail);
return CreatedAtAction(nameof(GetTask), new { id = task.Id }, task);
}
/// <summary>
/// Gets a task by ID
/// </summary>
[HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetTask(Guid id)
{
var task = await _taskService.GetTaskByIdAsync(id);
return Ok(task);
}
/// <summary>
/// Gets paginated list of tasks
/// </summary>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> GetTasks([FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 10)
{
var tasks = await _taskService.GetTasksAsync(pageNumber, pageSize);
return Ok(tasks);
}
/// <summary>
/// Adds hours to a task
/// </summary>
[HttpPut("{id}/hours")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> AddHours(Guid id, [FromBody] AddHoursRequest request)
{
await _taskService.AddTaskHoursAsync(id, request.Hours);
return NoContent();
}
/// <summary>
/// Generates an invoice for a task
/// </summary>
[HttpPost("{id}/invoice")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> GenerateInvoice(Guid id)
{
var invoice = await _taskService.GenerateInvoiceAsync(id);
return CreatedAtAction(nameof(GetTask), new { id = invoice.TaskId }, invoice);
}
}
public record CreateTaskRequest(string Title, decimal HourlyRate, string ClientEmail);
public record AddHoursRequest(decimal Hours);
}
// Freelancer.API/Validators/CreateTaskRequestValidator.cs
using FluentValidation;
namespace Freelancer.API.Validators
{
public class CreateTaskRequestValidator : AbstractValidator<CreateTaskRequest>
{
public CreateTaskRequestValidator()
{
RuleFor(x => x.Title)
.NotEmpty().WithMessage("Title is required")
.MaximumLength(200).WithMessage("Title cannot exceed 200 characters");
RuleFor(x => x.HourlyRate)
.GreaterThanOrEqualTo(0).WithMessage("Hourly rate must be non-negative")
.LessThanOrEqualTo(1000).WithMessage("Hourly rate cannot exceed $1000");
RuleFor(x => x.ClientEmail)
.NotEmpty().WithMessage("Client email is required")
.EmailAddress().WithMessage("Invalid email format");
}
}
}
Configure services, logging, and Swagger (program.cs)
// Freelancer.API/Program.cs
using Freelancer.Application.Services;
using Freelancer.Core.Interfaces;
using Freelancer.Infrastructure.Data;
using Freelancer.Infrastructure.Repositories;
using Microsoft.EntityFrameworkCore;
using Serilog;
using Freelancer.API.Middleware;
using FluentValidation;
using Freelancer.API.Validators;
using Microsoft.OpenApi.Models;
var builder = WebApplication.CreateBuilder(args);
// Configure Serilog
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
.WriteTo.Console()
.WriteTo.File("logs/log-.txt", rollingInterval: RollingInterval.Day)
.CreateLogger();
builder.Host.UseSerilog();
// Add services
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
// Configure Swagger
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Freelancer Task Automation API",
Version = "v1",
Description = "API for managing freelancer tasks and invoices"
});
var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
c.IncludeXmlComments(xmlPath);
});
// Configure Entity Framework
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// Register services
builder.Services.AddScoped<ITaskService, TaskService>();
builder.Services.AddScoped<ITaskRepository, TaskRepository>();
builder.Services.AddScoped<IInvoiceRepository, InvoiceRepository>();
// Register validators
builder.Services.AddScoped<IValidator<CreateTaskRequest>, CreateTaskRequestValidator>();
// Configure CORS for production
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowSpecificOrigins",
policy =>
{
policy.WithOrigins(builder.Configuration.GetSection("AllowedOrigins").Get<string[]>() ?? new[] { "*" })
.AllowAnyHeader()
.AllowAnyMethod();
});
});
var app = builder.Build();
// Configure middleware pipeline
app.UseMiddleware<ExceptionMiddleware>();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Freelancer API v1"));
}
app.UseHttpsRedirection();
app.UseCors("AllowSpecificOrigins");
app.UseSerilogRequestLogging();
app.MapControllers();
// Apply migrations on startup (development only)
if (app.Environment.IsDevelopment())
{
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
dbContext.Database.Migrate();
}
}
app.Run();
Step 5: Testing
// Freelancer.Tests/TaskServiceTests.cs
using Freelancer.Application.Services;
using Freelancer.Core.Entities;
using Freelancer.Core.Interfaces;
using Freelancer.Core.Exceptions;
using Moq;
using Xunit;
namespace Freelancer.Tests
{
public class TaskServiceTests
{
private readonly Mock<ITaskRepository> _taskRepoMock;
private readonly Mock<IInvoiceRepository> _invoiceRepoMock;
private readonly TaskService _service;
public TaskServiceTests()
{
_taskRepoMock = new Mock<ITaskRepository>();
_invoiceRepoMock = new Mock<IInvoiceRepository>();
_service = new TaskService(_taskRepoMock.Object, _invoiceRepoMock.Object);
}
[Fact]
public async Task GenerateInvoiceAsync_ValidTask_CreatesInvoice()
{
// Arrange
var task = new FreelancerTask("Design Website", 50m, "client@example.com");
task.AddHours(10m);
_taskRepoMock.Setup(r => r.GetByIdAsync(task.Id)).ReturnsAsync(task);
// Act
var invoice = await _service.GenerateInvoiceAsync(task.Id);
// Assert
Assert.Equal(task.Id, invoice.TaskId);
Assert.Equal(500m, invoice.Amount); // 10 hours * $50/hour
_invoiceRepoMock.Verify(r => r.AddAsync(It.IsAny<Invoice>()), Times.Once);
_taskRepoMock.Verify(r => r.UpdateAsync(It.IsAny<FreelancerTask>()), Times.Once);
}
[Fact]
public async Task GenerateInvoiceAsync_TaskNotFound_ThrowsException()
{
// Arrange
var taskId = Guid.NewGuid();
_taskRepoMock.Setup(r => r.GetByIdAsync(taskId)).ReturnsAsync((FreelancerTask)null);
// Act & Assert
await Assert.ThrowsAsync<TaskNotFoundException>(() =>
_service.GenerateInvoiceAsync(taskId));
}
[Fact]
public async Task GenerateInvoiceAsync_InsufficientHours_ThrowsException()
{
// Arrange
var task = new FreelancerTask("Design Website", 50m, "client@example.com");
task.AddHours(0.25m); // Less than minimum
_taskRepoMock.Setup(r => r.GetByIdAsync(task.Id)).ReturnsAsync(task);
// Act & Assert
await Assert.ThrowsAsync<InsufficientHoursException>(() =>
_service.GenerateInvoiceAsync(task.Id));
}
}
}
Async/Await Best Practices
When working with async operations:
Always use
async/await
for I/O operationsAvoid
.Result
or.Wait()
to prevent deadlocksUse
ConfigureAwait(false)
in library codeReturn
Task
directly when not using the result
// Good practice
public async Task<FreelancerTask> GetTaskAsync(Guid id)
{
return await _repository.GetByIdAsync(id); // Await for exception handling
}
// Better when you don't need exception handling
public Task<FreelancerTask> GetTaskAsync(Guid id)
{
return _repository.GetByIdAsync(id); // Direct task return
}
Conclusion
Part 1 built a Freelancer Task Automation API with Clean Architecture, ready for local development. It's unique, practical, and testable, ideal for SaaS-like applications. We've covered:
Clean Architecture principles and benefits
Domain-driven design with proper exception handling
Repository pattern for data access flexibility
API documentation with Swagger
Input validation with FluentValidation
Comprehensive error handling
Unit testing strategies
Conclusion
Part 1 built a Freelancer Task Automation API with Clean Architecture, ready for local development. It's unique, practical, and testable, ideal for SaaS-like applications. We've covered:
Clean Architecture principles and benefits
Domain-driven design with proper exception handling
Repository pattern for data access flexibility
API documentation with Swagger
Input validation with FluentValidation
Comprehensive error handling
Unit testing strategies
Production Considerations for Part 1
While our API has a solid architectural foundation, here are production aspects to consider even at this stage:
Code Quality
Consider adding code analysis tools (SonarQube, .editorconfig)
Implement code coverage requirements (aim for 80%+ on business logic)
Use feature branches and pull request reviews
API Design
Version your API from the start (
/api/v1/tasks
)Implement consistent response formats
Add request/response logging for debugging
Consider API rate limiting even for internal use
Development Practices
Use environment-specific configuration files
Never commit secrets or connection strings
Implement database migrations strategy early
Set up structured logging from day one
Performance
Add database indexes on frequently queried fields
Implement pagination for all list endpoints
Use async/await consistently
Profile and load test early
These practices are easier to implement from the beginning than to retrofit later. Part 2 will build upon this foundation with Azure deployment, authentication, email notifications, and full production readiness.
Stay tuned!
Subscribe to my newsletter
Read articles from Challa directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
