Are you a beginner looking to create a portfolio to impress recruiters? Or perhaps an experienced developer looking to build better basic apps ready to evolve? Learn how to create a CRUD application in ASP.NET Core that goes beyond the basics and has everything you need to stand out in the real world.
In this post, you’ll learn how to structure an ASP.NET Core project following CRUD best practices, even when the premise is simple. The idea here is to build something you can actually use as a portfolio.
In addition, we’ll focus on decisions that experienced developers make daily: how to avoid unnecessary coupling, how to better model the domain, and how to prepare your application to grow without becoming chaotic.
By the end of the post, you’ll have a solid foundation on how to create a CRUD application ready to evolve into something bigger, maybe with authentication, messaging, caching, or even an event-driven architecture.
The Problem with Generic CRUDs
You’ve probably faced a requirement like this at some point: Create a simple, straightforward CRUD that only performs the basic operations of Create, Read, Update and Delete, with entities representing tables, and everything working in a few minutes. And that’s not wrong. The problem starts when this same model, designed for prototyping, is taken to production without being prepared for future evolution.
One of the clearest signs of a basic CRUD is the use of anemic entities, classes that only have properties, without any behavior:
public class User
{
public string Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
}
Here, the entity does not protect its own state. Therefore, it can be created with an empty name, it can have an invalid email, it can be changed from anywhere. In other words, there is no business rule, just a simple data structure.
Another classic symptom of a prematurely scaled CRUD is having rules and validations scattered throughout the code. In basic CRUDs, when rules appear, they are scattered a bit in the Controller class, a bit in the Service class, and in the worst cases they don’t even exist.
Consider the example below:
if (string.IsNullOrWhiteSpace(user.Name))
return Results.BadRequest();
This solves the immediate problem, but creates a bigger one: the rule doesn’t belong to the domain, it’s loose in the system. Therefore, as the system grows, validations can be duplicated, lose consistency and become harder to find everywhere because they are scattered.
The biggest problem here is that you’ve only modeled the data, not the system. Basic CRUDs focus on basic operations: creating, reading, updating and deleting data. But real systems are about behavior: A user can register, they can deactivate their account, they can change their email based on internal validations, they can have a preferred name, they can change their address …
When you only model data, the system loses meaning and becomes just a data handling tool, which can easily be replaced by anything else, like a simple Excel spreadsheet for example.
The opposite of this are applications that, despite using only basic operations, are prepared for new features that will likely be needed. The image below illustrates the main points of both approaches:
Creating a CRUD Ready for Production
Now that we’ve reviewed some examples to avoid, let’s see how to create a complete CRUD application, organizing each part of the code according to best practices and preparing the project for evolution. You can access the complete project code in this GitHub repository: Campus Hub source code.
Architecture and Basic Structure
The application will be a CRUD to manage university courses, which can be registered, updated and deactivated. We will use the principles of Clean Architecture combined with tactical Domain-Driven Design (DDD) to create the basic structure of the project. Thus, the application will have the following project organization:
src/
- Presentation ->
CampusHub.Api - Application ->
CampusHub.Application - Domain ->
CampusHub.Domain - Infrastructure ->
CampusHub.Infrastructure
Let’s run the .NET commands to create the projects. In a terminal, execute the command below:
dotnet new sln -n CampusHub
This will create a new solution (CampusHub.sln). Then, to create the projects within the src directory, run the following command:
dotnet new webapi -n CampusHub.Api -o src/CampusHub.Api
dotnet new classlib -n CampusHub.Application -o src/CampusHub.Application
dotnet new classlib -n CampusHub.Domain -o src/CampusHub.Domain
dotnet new classlib -n CampusHub.Infrastructure -o src/CampusHub.Infrastructure
Now, to add the projects to the solution, run the following command:
dotnet sln add src/CampusHub.Api/CampusHub.Api.csproj
dotnet sln add src/CampusHub.Application/CampusHub.Application.csproj
dotnet sln add src/CampusHub.Domain/CampusHub.Domain.csproj
dotnet sln add src/CampusHub.Infrastructure/CampusHub.Infrastructure.csproj
Finally, run the following commands to add the dependencies between the projects:
dotnet add src/CampusHub.Application reference src/CampusHub.Domain
dotnet add src/CampusHub.Infrastructure reference src/CampusHub.Domain
dotnet add src/CampusHub.Infrastructure reference src/CampusHub.Application
dotnet add src/CampusHub.Api reference src/CampusHub.Application
dotnet add src/CampusHub.Api reference src/CampusHub.Infrastructure
Then, in the CampusHub.Infrastructure.cs add the following NuGet packages:
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0" />
</ItemGroup>
Domain Layer
In the domain layer, we will place the project’s entities and enums. Therefore, we will have a class to represent the Course entity and an enum to represent the course statuses. So, within the CampusHub.Domain project, create a folder called Entities and add the following class inside it:
using CampusHub.Domain.Enums;
namespace CampusHub.Domain.Entities;
public class Course
{
private const int MinNameLength = 3;
private const int MaxNameLength = 200;
private const int MinWorkload = 1;
public Guid Id { get; private set; }
public string Code { get; private set; } = string.Empty;
public string Name { get; private set; } = string.Empty;
public int WorkloadHours { get; private set; }
public int MaxStudents { get; private set; }
public CourseStatus Status { get; private set; }
// EF Core requirement
private Course() { }
private Course(string code, string name, int workloadHours, int maxStudents)
{
Id = Guid.NewGuid();
SetCode(code);
SetName(name);
SetWorkload(workloadHours);
SetMaxStudents(maxStudents);
Status = CourseStatus.Draft;
}
public static Course Create(string code, string name, int workloadHours, int maxStudents)
{
return new Course(code, name, workloadHours, maxStudents);
}
// Behavior methods
public void UpdateDetails(string name, int workloadHours, int maxStudents)
{
EnsureNotArchived();
SetName(name);
SetWorkload(workloadHours);
SetMaxStudents(maxStudents);
}
public void ChangeCapacity(int maxStudents)
{
EnsureNotArchived();
SetMaxStudents(maxStudents);
}
public void Activate()
{
if (Status == CourseStatus.Active)
throw new InvalidOperationException("Course is already active.");
Status = CourseStatus.Active;
}
public void Archive()
{
if (Status == CourseStatus.Archived)
throw new InvalidOperationException("Course is already archived.");
Status = CourseStatus.Archived;
}
// Private validation logic
private void SetCode(string code)
{
if (string.IsNullOrWhiteSpace(code))
throw new ArgumentException("Course code cannot be empty.");
Code = code.Trim().ToUpper();
}
private void SetName(string name)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Course name cannot be empty.");
if (name.Length < MinNameLength || name.Length > MaxNameLength)
throw new ArgumentException($"Course name must be between {MinNameLength} and {MaxNameLength} characters.");
Name = name.Trim();
}
private void SetWorkload(int workloadHours)
{
if (workloadHours < MinWorkload)
throw new ArgumentException("Workload must be greater than zero.");
WorkloadHours = workloadHours;
}
private void SetMaxStudents(int maxStudents)
{
if (maxStudents <= 0)
throw new ArgumentException("Max students must be greater than zero.");
MaxStudents = maxStudents;
}
private void EnsureNotArchived()
{
if (Status == CourseStatus.Archived)
throw new InvalidOperationException("Archived courses cannot be modified.");
}
}
Note that this entity has an excellent structure. It is not anemic, meaning it has behaviors such as the Activate(), Archive() and UpdateDetails() methods.
Furthermore, you can never create an invalid Course because the verification methods protect against creating an invalid state. For example, an archived course cannot be modified, and an active course cannot be reactivated.
Now, create a new folder called Enums and add the following enum to it:
namespace CampusHub.Domain.Enums;
public enum CourseStatus
{
Draft = 0,
Active = 1,
Archived = 2
}
Application Layer
The application layer is where we will configure the service classes with CRUD methods and Data Transfer Objects (DTOs). So, within the CampusHub.Application project, create a new folder called DTOs and create the following records inside it:
namespace CampusHub.Application.DTOs;
public record CourseResponseDto(
Guid Id,
string Code,
string Name,
int WorkloadHours,
int MaxStudents,
string Status
);
namespace CampusHub.Application.DTOs;
public record CreateCourseDto(
string Code,
string Name,
int WorkloadHours,
int MaxStudents
);
namespace CampusHub.Application.DTOs;
public record UpdateCourseDto(
string Name,
int WorkloadHours,
int MaxStudents
);
Then, create a new folder called Interfaces and add the following interfaces to it:
using CampusHub.Domain.Entities;
namespace CampusHub.Application.Interfaces;
public interface ICourseRepository
{
Task AddAsync(Course course);
Task<Course?> GetByIdAsync(Guid id);
Task<List<Course>> GetAllAsync();
Task Update(Course course);
Task<bool> ExistsByCodeAsync(string code);
}
using CampusHub.Application.DTOs;
namespace CampusHub.Application.Interfaces;
public interface ICourseService
{
Task<Guid> CreateAsync(CreateCourseDto dto);
Task UpdateAsync(Guid id, UpdateCourseDto dto);
Task ActivateAsync(Guid id);
Task ArchiveAsync(Guid id);
Task<CourseResponseDto?> GetByIdAsync(Guid id);
Task<List<CourseResponseDto>> GetAllAsync();
}
Finally, create a new folder called Services and add the class below to it:
using CampusHub.Application.Interfaces;
using CampusHub.Domain.Entities;
using CampusHub.Application.DTOs;
namespace CampusHub.Application.Services;
public class CourseService : ICourseService
{
private readonly ICourseRepository _repository;
public CourseService(ICourseRepository repository)
{
_repository = repository;
}
public async Task<Guid> CreateAsync(CreateCourseDto dto)
{
var exists = await _repository.ExistsByCodeAsync(dto.Code);
if (exists)
throw new InvalidOperationException("Course code already exists.");
var course = Course.Create(
dto.Code,
dto.Name,
dto.WorkloadHours,
dto.MaxStudents
);
await _repository.AddAsync(course);
return course.Id;
}
public async Task UpdateAsync(Guid id, UpdateCourseDto dto)
{
var course = await _repository.GetByIdAsync(id)
?? throw new InvalidOperationException("Course not found.");
course.UpdateDetails(dto.Name, dto.WorkloadHours, dto.MaxStudents);
await _repository.Update(course);
}
public async Task ActivateAsync(Guid id)
{
var course = await _repository.GetByIdAsync(id)
?? throw new InvalidOperationException("Course not found.");
course.Activate();
await _repository.Update(course);
}
public async Task ArchiveAsync(Guid id)
{
var course = await _repository.GetByIdAsync(id)
?? throw new InvalidOperationException("Course not found.");
course.Archive();
await _repository.Update(course);
}
public async Task<CourseResponseDto?> GetByIdAsync(Guid id)
{
var course = await _repository.GetByIdAsync(id);
if (course is null)
return null;
return MapToResponse(course);
}
public async Task<List<CourseResponseDto>> GetAllAsync()
{
var courses = await _repository.GetAllAsync();
return courses.Select(MapToResponse).ToList();
}
private static CourseResponseDto MapToResponse(Course course)
{
return new CourseResponseDto(
course.Id,
course.Code,
course.Name,
course.WorkloadHours,
course.MaxStudents,
course.Status.ToString()
);
}
}
Here we have all the methods we need to execute the CRUD functions. Note that to modify the state of the objects we use the methods created in the domain class.
Infrastructure Layer
Now let’s create the infrastructure layer, used for communication with external parts of the application such as external APIs and databases. Within the CampusHub.Infrastructure project, create a new folder called Data and add the following classes to it:
using CampusHub.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace CampusHub.src.CampusHub.Infrastructure.Data;
public class AppDbContext : DbContext
{
public DbSet<Course> Courses => Set<Course>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
base.OnModelCreating(modelBuilder);
}
}
using CampusHub.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CampusHub.src.CampusHub.Infrastructure.Data;
public class CourseConfiguration : IEntityTypeConfiguration<Course>
{
public void Configure(EntityTypeBuilder<Course> builder)
{
builder.ToTable("Courses");
builder.HasKey(c => c.Id);
builder.Property(c => c.Code)
.IsRequired()
.HasMaxLength(20);
builder.HasIndex(c => c.Code)
.IsUnique();
builder.Property(c => c.Name)
.IsRequired()
.HasMaxLength(200);
builder.Property(c => c.WorkloadHours)
.IsRequired();
builder.Property(c => c.MaxStudents)
.IsRequired();
builder.Property(c => c.Status)
.IsRequired()
.HasConversion<int>(); // Enum -> int
builder.Property(c => c.Id)
.ValueGeneratedNever();
}
}
Here we made the necessary configurations for the EF Core implementation, defining database details such as the table name, maximum number of characters for the Name property and others.
Next, create a new folder called Repositories and add the following class to it:
using CampusHub.Application.Interfaces;
using CampusHub.Domain.Entities;
using CampusHub.src.CampusHub.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace CampusHub.Infrastructure.Repositories;
public class CourseRepository : ICourseRepository
{
private readonly AppDbContext _context;
public CourseRepository(AppDbContext context)
{
_context = context;
}
public async Task AddAsync(Course course)
{
await _context.Courses.AddAsync(course);
await _context.SaveChangesAsync();
}
public async Task<Course?> GetByIdAsync(Guid id)
{
return await _context.Courses.FirstOrDefaultAsync(c => c.Id == id);
}
public async Task<List<Course>> GetAllAsync()
{
return await _context.Courses.ToListAsync();
}
public async Task<bool> ExistsByCodeAsync(string code)
{
var normalizedCode = code.Trim().ToUpper();
return await _context.Courses
.AnyAsync(c => c.Code == normalizedCode);
}
public async Task Update(Course course)
{
_context.Courses.Update(course);
await _context.SaveChangesAsync();
}
}
In the repository class, we create the implementation of the CRUD methods that execute the operations on the database.
The last class in the infrastructure layer will be used to configure the dependency injection and define the connection string. So, inside the project add the class below:
using CampusHub.Application.Interfaces;
using CampusHub.Infrastructure.Repositories;
using CampusHub.src.CampusHub.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace CampusHub.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<AppDbContext>(options =>
options.UseMySql(
configuration.GetConnectionString("DefaultConnection"),
ServerVersion.AutoDetect(configuration.GetConnectionString("DefaultConnection"))
));
services.AddScoped<ICourseRepository, CourseRepository>();
return services;
}
}
API Layer
The API is the final layer of the application, here we will define the controller classes and other settings such as the database connection string. Inside the Controllers folder, add the following controller:
using CampusHub.Application.DTOs;
using CampusHub.Application.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace CampusHub.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class CoursesController : ControllerBase
{
private readonly ICourseService _service;
public CoursesController(ICourseService service)
{
_service = service;
}
[HttpPost]
public async Task<IActionResult> Create(CreateCourseDto dto)
{
var id = await _service.CreateAsync(dto);
return CreatedAtAction(nameof(GetById), new { id }, null);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var course = await _service.GetByIdAsync(id);
if (course is null)
return NotFound();
return Ok(course);
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
var courses = await _service.GetAllAsync();
return Ok(courses);
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, UpdateCourseDto dto)
{
await _service.UpdateAsync(id, dto);
return NoContent();
}
[HttpPost("{id:guid}/activate")]
public async Task<IActionResult> Activate(Guid id)
{
await _service.ActivateAsync(id);
return NoContent();
}
[HttpPost("{id:guid}/archive")]
public async Task<IActionResult> Archive(Guid id)
{
await _service.ArchiveAsync(id);
return NoContent();
}
}
Then, in the Program class, replace the existing code with the code below:
using CampusHub.Application.Services;
using CampusHub.Application.Interfaces;
using CampusHub.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
// Services
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
// Application
builder.Services.AddScoped<ICourseService, CourseService>();
// Infrastructure
builder.Services.AddInfrastructure(builder.Configuration);
// Build
var app = builder.Build();
// Middleware
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Finally, in the file appsettings.json, add the following configuration. (Don’t forget to add your credentials!):
"ConnectionStrings": {
"DefaultConnection": "server=localhost;port=3306;database=campushub;user=YOUR_USER;password=YOUR_PASSWORD;"
}
,
Running EF Core Commands
To apply the migration commands, in the project root, open a new terminal and run the commands below.
Creating the Migration Files
dotnet ef migrations add InitialCreate --project src/CampusHub.Infrastructure --startup-project src/CampusHub.Api --output-dir Data/Migrations
Applying the Migration Commands
dotnet ef database update --project src/CampusHub.Infrastructure --startup-project src/CampusHub.Api
What Does the Final Structure Look Like?
After implementing all the steps above, the project will have the following structure:
When Does a Complete CRUD Not Pay Off?
In this post, we created a complete CRUD, with all the necessary elements for its evolution. However, this complexity can exponentially increase development time and the level of knowledge required to maintain the project.
With this in mind, in some scenarios, simplicity is preferable. For example, when the system basically stores and retrieves data without relevant business rules. In scenarios such as basic registrations, auxiliary tables or internal administrative panels, adding layers such as services, rich entities and DDD patterns tends to generate more complexity than value, making the code more difficult to maintain without a justified need.
It is also not worthwhile to invest in a sophisticated architecture in the initial phases of a product, such as MVPs or systems still in validation. In these cases, the priority is speed and adaptation to frequent changes, and a simple structure allows for faster evolution without the burden of unnecessary abstractions.
On the other hand, a more elaborate CRUD makes sense when the domain has important rules, states, complex validations or when errors can have a significant impact. In these scenarios, a more structured architecture, as demonstrated in the post, helps protect the domain, maintain consistency and facilitate the system’s evolution over time (all parts are in their proper places).
The final decision of whether or not to use a complete and evolve-ready CRUD should therefore always take into account the level of complexity of the problem.
Conclusion and Next Steps
Knowing the best approach when creating new web applications is always a challenge, but we must keep in mind that if the project requires complex business rules, validations and errors that can have a significant impact, this is a strong indication that the application should not be a simple and generic CRUD.
In this post, we learned how to create a complete CRUD, with each layer representing a part of Clean Architecture, and using DDD development principles to implement a well-structured domain with private properties and behaviors.
But far beyond the basic structure presented in this post, a complete CRUD also involves unit tests, complex validations (FluentValidation), authentication and authorization with modern methods (JW Tokens for example), and many other elements. I hope this post serves as a starting point and helps you create CRUD applications that are not only ready to function, but also to evolve with quality over time.














