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

ChallaChalla
12 min read

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
    }
}
💡
Keep entities focused on core data and validation. Domain exceptions make error handling more expressive and help maintain business rule integrity.

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);
        }
    }
}
💡
Centralize business logic in services. The repository pattern might seem like unnecessary abstraction over EF Core, but it provides testability and the flexibility to switch data access technologies if needed.

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();
        }
    }
}
💡
EF Core simplifies data access. Indexes on frequently queried fields (ClientEmail, TaskId) improve performance. For high-performance needs, consider Dapper, but only after profiling.

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();
💡
Serilog, exception middleware, and Swagger are production must-haves. FluentValidation provides cleaner validation than data annotations for complex scenarios.

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 operations

  • Avoid .Result or .Wait() to prevent deadlocks

  • Use ConfigureAwait(false) in library code

  • Return 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!

0
Subscribe to my newsletter

Read articles from Challa directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Challa
Challa