.NET 10 + Angular 22: The Generic Entity Service Pattern That Eliminates Your CRUD Boilerplate
Every project I join has the same problem. Ten entities, ten controllers, ten services, ten data access classes and all doing the exact same thing. A ProductService that's a copy-paste of CustomerService, which is a copy-paste of OrderService. Nobody questions it. It just grows.
I got fed up with this years ago. Here's the pattern I was using for the last 10 years.
The Problem in Plain English
When you write ProductController, CustomerController, and OrderController with near-identical CRUD operations, you're paying a maintenance tax every time your team adds an entity. New developer? They copy the last controller and change the type name. New endpoint pattern needed? You touch 15 files. A bug in the paging logic? You fix it in 10 places.
The solution is obvious once you see it: one generic implementation, auto-discovered at startup, zero boilerplate per entity.
The .NET 10 Side
1. BaseEntity
Every entity in the system inherits from this. No exceptions.
// BaseEntity.cs
namespace MyApp.Core;
public abstract class BaseEntity
{
public Guid Id { get; set; } = Guid.CreateVersion7();
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt
{
get;
set => field = value.Kind == DateTimeKind.Utc ? value : value.ToUniversalTime();
} = DateTime.UtcNow;
}
Two things worth calling out here. First, Guid.CreateVersion7() this was introduced in .NET 9 and fully supported in .NET 10, it generates a time-ordered UUID (RFC 9562 v7). Unlike Guid.NewGuid(), these sort chronologically in your database, which means better index performance on Id columns without a separate CreatedAt index. Second, UpdatedAt uses the C# 14 field keyword: a semi-auto property that validates incoming values are UTC without needing an explicit private backing field. One accessor, zero boilerplate.
Simple. If you want soft deletes, add IsDeleted here and handle it in the data access layer centrally. One change, every entity benefits.
2. Data Access Layer
The interface is the contract. Keep it clean.
// IGenericDataAccess.cs
namespace MyApp.Core.DataAccess;
public interface IGenericDataAccess<T> where T : BaseEntity
{
Task<T?> GetAsync(Guid id, CancellationToken ct = default);
Task<(IEnumerable<T> Items, int TotalCount)> GetPagedAsync(int page, int pageSize, CancellationToken ct = default);
Task<T> CreateAsync(T entity, CancellationToken ct = default);
Task<T> UpdateAsync(T entity, CancellationToken ct = default);
Task DeleteAsync(Guid id, CancellationToken ct = default);
}
The implementation uses EF Core 10. Primary constructors keep it tidy.
// GenericDataAccess.cs
namespace MyApp.Core.DataAccess;
public class GenericDataAccess<T>(AppDbContext db) : IGenericDataAccess<T>
where T : BaseEntity
{
private readonly DbSet<T> _set = db.Set<T>();
public async Task<T?> GetAsync(Guid id, CancellationToken ct = default)
=> await _set.AsNoTracking().FirstOrDefaultAsync(e => e.Id == id, ct);
public async Task<(IEnumerable<T> Items, int TotalCount)> GetPagedAsync(
int page, int pageSize, CancellationToken ct = default)
{
var query = _set.AsNoTracking().OrderByDescending(e => e.CreatedAt);
var total = await query.CountAsync(ct);
var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(ct);
return (items, total);
}
public async Task<T> CreateAsync(T entity, CancellationToken ct = default)
{
entity.CreatedAt = DateTime.UtcNow;
entity.UpdatedAt = DateTime.UtcNow;
_set.Add(entity);
await db.SaveChangesAsync(ct);
return entity;
}
public async Task<T> UpdateAsync(T entity, CancellationToken ct = default)
{
entity.UpdatedAt = DateTime.UtcNow;
_set.Update(entity);
await db.SaveChangesAsync(ct);
return entity;
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var entity = await _set.FindAsync([id], ct)
?? throw new KeyNotFoundException($"Entity {typeof(T).Name} with id {id} not found.");
_set.Remove(entity);
await db.SaveChangesAsync(ct);
}
}
One class. Every entity. Done.
EF Core 10 is also where named query filters shine for this pattern, if you add IsDeleted to BaseEntity, you can register a named filter in AppDbContext and disable it selectively per query, without affecting the generic layer at all.
3. Service Layer
You might ask: why have a service layer at all if it just wraps data access? Because it won't stay thin forever. Business rules, validation, events, they belong here. Start thin, grow where needed.
// IGenericEntityService.cs
namespace MyApp.Core.Services;
public interface IGenericEntityService<T> where T : BaseEntity
{
Task<T?> GetAsync(Guid id, CancellationToken ct = default);
Task<(IEnumerable<T> Items, int TotalCount)> GetPagedAsync(int page, int pageSize, CancellationToken ct = default);
Task<T> CreateAsync(T entity, CancellationToken ct = default);
Task<T> UpdateAsync(T entity, CancellationToken ct = default);
Task DeleteAsync(Guid id, CancellationToken ct = default);
}
// GenericEntityService.cs
namespace MyApp.Core.Services;
public class GenericEntityService<T>(IGenericDataAccess<T> dataAccess) : IGenericEntityService<T>
where T : BaseEntity
{
public Task<T?> GetAsync(Guid id, CancellationToken ct = default)
=> dataAccess.GetAsync(id, ct);
public Task<(IEnumerable<T> Items, int TotalCount)> GetPagedAsync(int page, int pageSize, CancellationToken ct = default)
=> dataAccess.GetPagedAsync(page, pageSize, ct);
public Task<T> CreateAsync(T entity, CancellationToken ct = default)
=> dataAccess.CreateAsync(entity, ct);
public Task<T> UpdateAsync(T entity, CancellationToken ct = default)
=> dataAccess.UpdateAsync(entity, ct);
public Task DeleteAsync(Guid id, CancellationToken ct = default)
=> dataAccess.DeleteAsync(id, ct);
}
If Product needs a custom price validation rule, you create a ProductService : GenericEntityService<Product> and override just that method. Everything else is inherited. That's the design.
4. The Generic Controller
This is where it gets interesting. A single controller serves every entity type.
// GenericEntityController.cs
namespace MyApp.Api.Controllers;
[ApiController]
[Route("api/entities/[controller]")]
public class GenericEntityController<T>(IGenericEntityService<T> service) : ControllerBase
where T : BaseEntity
{
[HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id, CancellationToken ct)
{
var entity = await service.GetAsync(id, ct);
return entity is null ? NotFound() : Ok(entity);
}
[HttpGet("paged")]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
var (items, total) = await service.GetPagedAsync(page, pageSize, ct);
return Ok(new { items, total, page, pageSize });
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] T entity, CancellationToken ct)
{
var created = await service.CreateAsync(entity, ct);
return CreatedAtAction(nameof(Get), new { id = created.Id }, created);
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] T entity, CancellationToken ct)
{
if (id != entity.Id) return BadRequest("ID mismatch.");
var updated = await service.UpdateAsync(entity, ct);
return Ok(updated);
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await service.DeleteAsync(id, ct);
return NoContent();
}
}
The route pattern api/entities/[controller] means your Product entity gets api/entities/product automatically, once wired up.
5. Auto-Registration at Startup
This is the part that makes the whole thing click. No manual registration per entity.
// ServiceCollectionExtensions.cs
namespace MyApp.Api.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddGenericEntityServices(
this IServiceCollection services,
params Assembly[] assemblies)
{
var entityTypes = assemblies
.SelectMany(a => a.GetTypes())
.Where(t => t.IsClass && !t.IsAbstract && t.IsSubclassOf(typeof(BaseEntity)))
.ToList();
foreach (var entityType in entityTypes)
{
// Register IGenericDataAccess<T>
var dataAccessInterface = typeof(IGenericDataAccess<>).MakeGenericType(entityType);
var dataAccessImpl = typeof(GenericDataAccess<>).MakeGenericType(entityType);
services.AddScoped(dataAccessInterface, dataAccessImpl);
// Register IGenericEntityService<T>
var serviceInterface = typeof(IGenericEntityService<>).MakeGenericType(entityType);
var serviceImpl = typeof(GenericEntityService<>).MakeGenericType(entityType);
services.AddScoped(serviceInterface, serviceImpl);
}
return services;
}
}
Controllers need their own wiring via a custom IApplicationFeatureProvider:
// GenericEntityControllerFeatureProvider.cs
namespace MyApp.Api.Infrastructure;
public class GenericEntityControllerFeatureProvider(IEnumerable<Assembly> assemblies)
: IApplicationFeatureProvider<ControllerFeature>
{
public void PopulateFeature(IEnumerable<ApplicationPart> parts, ControllerFeature feature)
{
var entityTypes = assemblies
.SelectMany(a => a.GetTypes())
.Where(t => t.IsClass && !t.IsAbstract && t.IsSubclassOf(typeof(BaseEntity)));
foreach (var entityType in entityTypes)
{
var controllerType = typeof(GenericEntityController<>).MakeGenericType(entityType);
feature.Controllers.Add(controllerType.GetTypeInfo());
}
}
}
Wire it all up in Program.cs:
// Program.cs
using MyApp.Api.Extensions;
using MyApp.Api.Infrastructure;
using MyApp.Core;
var builder = WebApplication.CreateBuilder(args);
var entityAssembly = typeof(BaseEntity).Assembly;
builder.Services
.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Default")))
.AddGenericEntityServices(entityAssembly)
.AddControllers()
.ConfigureApplicationPartManager(manager =>
manager.FeatureProviders.Add(
new GenericEntityControllerFeatureProvider([entityAssembly])));
var app = builder.Build();
app.MapControllers();
app.Run();
Add an entity, get a full REST API. Zero boilerplate.
6. A Concrete Entity Example
// Product.cs
namespace MyApp.Core.Entities;
public class Product : BaseEntity
{
public string Name
{
get;
set => field = string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim();
} = string.Empty;
public decimal Price { get; set; }
public int StockQuantity { get; set; }
}
C# 14's field keyword earns its keep in domain entities. Name auto-trims on assignment without a private backing field, it's just part of the property accessor now. That's it. Product now has GET /api/entities/product/{id}, GET /api/entities/product/paged, POST, PUT, and DELETE. No controller, no service, no data access class written by hand.
The Angular 22 Side
The frontend follows the same philosophy. One generic service, typed per entity. Angular 22 makes this even cleaner, zoneless change detection is now the default for new projects, and httpResource is stable, giving us signal-native data fetching without a subscription in sight.
First, configure zoneless in your app config:
// app.config.ts
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideHttpClient(),
],
};
No zone.js in your polyfills. Lighter bundle, faster startup, and Angular only runs change detection when signals or framework entry points, template events, HTTP responses, the router actually fire.
7. GenericEntityService
// generic-entity.service.ts
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../environments/environment';
export interface PagedResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
@Injectable()
export abstract class GenericEntityService<T extends { id: string }> {
private readonly http = inject(HttpClient);
protected abstract entityName: string;
getBaseUrl(): string {
return `${environment.apiUrl}/entities/${this.entityName.toLowerCase()}`;
}
get(id: string): Observable<T> {
return this.http.get<T>(`${this.getBaseUrl()}/${id}`);
}
getPaged(page: number = 1, pageSize: number = 20): Observable<PagedResult<T>> {
const params = new HttpParams()
.set('page', page.toString())
.set('pageSize', pageSize.toString());
return this.http.get<PagedResult<T>>(`${this.getBaseUrl()}/paged`, { params });
}
create(entity: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): Observable<T> {
return this.http.post<T>(this.getBaseUrl(), entity);
}
update(id: string, entity: T): Observable<T> {
return this.http.put<T>(`${this.getBaseUrl()}/${id}`, entity);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.getBaseUrl()}/${id}`);
}
}
Notice the Omit<T, 'id' | 'createdAt' | 'updatedAt'> on create. The server owns those fields. Angular should never pretend otherwise.
8. Typed ProductService
// product.service.ts
import { Injectable } from '@angular/core';
import { GenericEntityService } from './generic-entity.service';
export interface Product {
id: string;
createdAt: string;
updatedAt: string;
name: string;
price: number;
stockQuantity: number;
}
@Injectable({ providedIn: 'root' })
export class ProductService extends GenericEntityService<Product> {
protected override entityName = 'product';
}
Seven lines. Full CRUD. Fully typed. The environment.apiUrl does all the routing work (there was another way of how we could handle this, without the generics I mean, and that is entirely up to you).
9. Component Usage
In Angular 22, httpResource replaces the ngOnInit + subscribe pattern for data fetching. It returns a resource object whose .value(), .isLoading(), and .error() are signals, no subscriptions, no teardown, no lifecycle hooks.
// product-list.component.ts
import { Component, inject } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { ProductService, Product } from './product.service';
import { PagedResult } from './generic-entity.service';
@Component({
selector: 'app-product-list',
standalone: true,
template: `
@if (products.isLoading()) {
<p>Loading</p>
}
@for (product of products.value()?.items ?? []; track product.id) {
<div>
<h3>{{ product.name }}</h3>
<p>{{ product.price }} - {{ product.stockQuantity }} in stock</p>
<button (click)="remove(product.id)">Delete</button>
</div>
}
`,
})
export class ProductListComponent {
private readonly productService = inject(ProductService);
readonly products = httpResource<PagedResult<Product>>(
() => `${this.productService.getBaseUrl()}/paged?page=1&pageSize=20`
);
remove(id: string): void {
this.productService.delete(id).subscribe(() => this.products.reload());
}
}
httpResource takes a reactive URL factory, wrap it in a computed or use a signal for the page number, and the resource automatically refetches when the page changes. No switchMap, no takeUntilDestroyed. Mutations still go through the service's Observable methods, and reload() triggers a fresh fetch after a delete.
Signals, standalone components, inject(), zoneless Angular 22 all the way. No lifecycle cruft.
What You Actually Eliminated
Let me be direct about the numbers. For a project with 10 entities, the traditional approach means writing roughly:
- 10 controllers — ~80 lines = 800 lines
- 10 service classes — ~60 lines = 600 lines
- 10 data access classes — ~80 lines = 800 lines
- 10 Angular services — ~50 lines = 500 lines
That's 2,700 lines of code that does nothing your generic pattern doesn't already cover. With this pattern, adding a new entity is: one C# class inheriting BaseEntity, one TypeScript interface, one 7-line Angular service. That's it.
The registration reflection runs once at startup. The controller feature provider costs you nothing at runtime. The abstractions are thin enough that overriding for complex entities is straightforward, you extend, you don't rewrite.
This is what I mean when I talk about making things 75% easier. You're not simplifying the concept of CRUD, you're eliminating the repetition entirely.
One Caveat Worth Mentioning
Generic patterns like this shine for standard CRUD. When an entity needs complex domain logic multi-step workflows, event sourcing, heavy validation break out of the pattern. Create a dedicated service, a dedicated controller. Don't contort the generic into something it was never meant to be. Know when to inherit, know when to step outside.
The pattern is a tool, not a religion.
What's Next
Add an IQueryableFilter<T> to the data access layer and you get typed server-side filtering without touching any entity-specific code. Add a BeforeCreate / BeforeUpdate hook to GenericEntityService<T> and you get lifecycle events for free. The scaffold is there just build on it.
The days of copy-pasting controllers are over. In the future we will also discuss how we can source generate code to achieve this same functionality, because generics take their toll in high throughput system if not handled/cached correctly, don't get me wrong, they are great but we need to always keep in mind the performance of the app, and generics if not cached take a chunk of our processing power.