The Problem With Most C# Codebases
Most .NET codebases I've worked on share the same problem: repetition at the foundation level.
Every entity has the same fields — Id, CreatedAt, UpdatedAt, CreatedBy — copy-pasted across every model. Every controller has the same try/catch blocks, the same null checks, the same 200/400/500 response patterns repeated across dozens of endpoints.
This kind of repetition isn't just tedious. It's a maintenance problem. When you need to change how audit fields are set, or standardise your error response format, you're doing it in 20 places instead of one.
Two abstractions fix most of it: a base entity and a base controller.
Base Entity
A base entity is an abstract class that holds the fields every model in your system should have:
public abstract class BaseEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public string? CreatedBy { get; set; }
}
Every model inherits from it:
public class Appointment : BaseEntity
{
public string ServiceType { get; set; } = string.Empty;
public DateTime SlotTime { get; set; }
public string Status { get; set; } = "Pending";
}
public class Customer : BaseEntity
{
public string FullName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}
The Appointment and Customer classes each have four fields — but they get Id, CreatedAt, UpdatedAt, and CreatedBy for free from the base.
Entity Framework Configuration
You configure the base entity fields once in OnModelCreating, and it applies to every entity that inherits it:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (typeof(BaseEntity).IsAssignableFrom(entityType.ClrType))
{
modelBuilder.Entity(entityType.ClrType)
.Property(nameof(BaseEntity.CreatedAt))
.HasDefaultValueSql("GETUTCDATE()");
}
}
}
One block of configuration. Applies everywhere. Add a new model, it inherits the same database-level defaults automatically.
Automatic UpdatedAt With SaveChanges Override
Override SaveChangesAsync in your DbContext to automatically set UpdatedAt whenever an entity is modified:
public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
var entries = ChangeTracker.Entries<BaseEntity>()
.Where(e => e.State == EntityState.Modified);
foreach (var entry in entries)
entry.Entity.UpdatedAt = DateTime.UtcNow;
return await base.SaveChangesAsync(ct);
}
Now UpdatedAt is always accurate, across every entity, without a single developer having to remember to set it manually.
Base Controller
The same principle applies to controllers. The patterns that repeat across every action — null checks, error handling, response formatting — belong in one place:
[ApiController]
[Route("api/[controller]")]
public abstract class BaseController : ControllerBase
{
protected IActionResult HandleResult<T>(T? result)
{
if (result == null) return NotFound(new { message = "Resource not found." });
return Ok(result);
}
protected IActionResult HandlePagedResult<T>(IEnumerable<T> items, int total, int page, int pageSize)
{
return Ok(new
{
items,
total,
page,
pageSize,
hasNext = (page * pageSize) < total
});
}
protected IActionResult HandleError(Exception ex, string? context = null)
{
return StatusCode(500, new
{
message = "An unexpected error occurred.",
context,
detail = ex.Message
});
}
}
Every controller inherits this:
public class AppointmentsController : BaseController
{
private readonly IAppointmentService _service;
public AppointmentsController(IAppointmentService service)
{
_service = service;
}
[HttpGet("{id}")]
public async Task<IActionResult> Get(Guid id)
{
var result = await _service.GetByIdAsync(id);
return HandleResult(result);
}
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var (items, total) = await _service.GetPagedAsync(page, pageSize);
return HandlePagedResult(items, total, page, pageSize);
}
}
No try/catch in the action. No if (result == null) return NotFound() repeated 30 times. The controller handles only what's specific to appointments — everything generic lives in the base.
Why This Matters at Scale
These abstractions feel like a small win when a project has five models and three controllers. They're a significant win when it has 30 models and 15 controllers.
Consider what it looks like without a base entity when requirements change:
- Add a
DeletedAtfield for soft deletes → update every model manually, miss two, ship a bug - Change
IdfrominttoGuid→ find and update every entity, every foreign key, every DTO - Add a
TenantIdfor multi-tenancy → copy-paste into every class, forget the ones added last month
With a base entity, each of these is a one-line change in one file.
The same is true for controllers. When you want to change your error response format — say, to include a correlation ID for tracing — you update HandleError in the base controller and it propagates everywhere instantly. Without a base controller, you're updating every catch block in every controller, hoping you don't miss one.
Real Usage: Embassy Booking Platform
On the Embassy of Kenya platform — a 60-page web application with an appointment booking system, media management backend, and 2FA-secured admin access — both of these patterns were in use from day one.
The BaseEntity provided consistent audit fields across every model: appointments, media items, users, admin logs. The BaseController standardised the response format across every endpoint, which made the frontend integration significantly more predictable — the frontend team knew exactly what a 404 looked like, what a 500 looked like, and what a successful paginated response looked like, because it was always the same shape.
That consistency doesn't happen by convention. It happens because the base class enforces it.
Generic Repository: Taking It Further
Once you have a base entity, a generic repository is a natural next step:
public interface IRepository<T> where T : BaseEntity
{
Task<T?> GetByIdAsync(Guid id);
Task<IEnumerable<T>> GetAllAsync();
Task AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(Guid id);
}
public class Repository<T> : IRepository<T> where T : BaseEntity
{
protected readonly AppDbContext _context;
protected readonly DbSet<T> _dbSet;
public Repository(AppDbContext context)
{
_context = context;
_dbSet = context.Set<T>();
}
public async Task<T?> GetByIdAsync(Guid id) =>
await _dbSet.FirstOrDefaultAsync(e => e.Id == id);
public async Task<IEnumerable<T>> GetAllAsync() =>
await _dbSet.ToListAsync();
public async Task AddAsync(T entity)
{
await _dbSet.AddAsync(entity);
await _context.SaveChangesAsync();
}
public async Task UpdateAsync(T entity)
{
_dbSet.Update(entity);
await _context.SaveChangesAsync();
}
public async Task DeleteAsync(Guid id)
{
var entity = await GetByIdAsync(id);
if (entity != null)
{
_dbSet.Remove(entity);
await _context.SaveChangesAsync();
}
}
}
Register it once in Program.cs:
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
Now every service that needs basic CRUD operations gets them through the generic repository. Specific services extend it with domain-specific queries. You never write the same GetByIdAsync implementation twice.
Summary
Three abstractions, applied consistently from the start of a project:
- BaseEntity — shared audit fields, configured once, inherited everywhere
- BaseController — shared response handling, null checks, and error formatting in one place
- Repository<T> — shared CRUD operations, extended per domain where needed
None of these are complex. The value isn't in the abstraction itself — it's in the consistency they enforce as the codebase grows. Six months into a project, these patterns are what allow you to move fast without breaking things you built three months ago.
If you're building a .NET backend and want to talk through architecture decisions for your project, get in touch.