Add a feature
A feature is a backend vertical slice plus a frontend screen. Both start by copying a _Template, so there's no blank page — you rename and fill in.
1. The backend slice
A slice is six files under Features/{Domain}/. Copy the template:
cp -r NetForge.Server/Features/_Template NetForge.Server/Features/ProjectsRename Template → Project (and template → project) throughout. Here's what each file becomes.
Models.cs — entity + DTO + request records:
public class Project
{
public int Id { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
public record ProjectDto(int Id, string Name, string? Description, DateTimeOffset CreatedAt);
public record CreateProjectRequest(string Name, string? Description);
public record UpdateProjectRequest(string Name, string? Description);Permissions.cs — one constant per action ([Description] feeds the admin catalog):
public static class ProjectPermissions
{
[Description("View projects")] public const string Read = "projects.read";
[Description("Create projects")] public const string Create = "projects.create";
[Description("Edit projects")] public const string Update = "projects.update";
[Description("Delete projects")] public const string Delete = "projects.delete";
}Validators.cs — an internal sealed validator per request (the ValidationFilter runs it automatically):
internal sealed class CreateProjectValidator : AbstractValidator<CreateProjectRequest>
{
public CreateProjectValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Description).MaximumLength(1000);
}
}Mappings.cs — static extension, no AutoMapper:
internal static class ProjectMappings
{
public static ProjectDto ToDto(this Project e) => new(e.Id, e.Name, e.Description, e.CreatedAt);
}EfConfig.cs — IEntityTypeConfiguration<T>:
internal sealed class ProjectConfig : IEntityTypeConfiguration<Project>
{
public void Configure(EntityTypeBuilder<Project> b)
{
b.ToTable("Projects");
b.HasKey(x => x.Id);
b.Property(x => x.Name).HasMaxLength(200).IsRequired();
}
}Endpoints.cs — the IFeatureEndpoints implementation. Note the filter placement: validation/performance per group, transaction per write handler:
public sealed class ProjectEndpoints : IFeatureEndpoints
{
public void Map(IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/projects")
.WithTags("Projects")
.AddEndpointFilter<ValidationFilter>()
.AddEndpointFilter<PerformanceFilter>();
group.MapGet("/", List).RequirePermission(ProjectPermissions.Read);
group.MapGet("/{id:int}", Get).RequirePermission(ProjectPermissions.Read);
group.MapPost("/", Create).RequirePermission(ProjectPermissions.Create).AddEndpointFilter<TransactionFilter>();
group.MapPut("/{id:int}", Update).RequirePermission(ProjectPermissions.Update).AddEndpointFilter<TransactionFilter>();
group.MapDelete("/{id:int}", Delete).RequirePermission(ProjectPermissions.Delete).AddEndpointFilter<TransactionFilter>();
}
private static async Task<IResult> Get(int id, AppDbContext db, CancellationToken ct) =>
await db.Set<Project>().AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct) is { } p
? Results.Ok(p.ToDto())
: throw new NotFoundException(nameof(Project), id); // → 404 ProblemDetails
// … List / Create / Update / Delete as in _Template
}Add the migration:
dotnet ef migrations add AddProjects --project NetForge.ServerThe slice is now live — you never touched Program.cs. Feature discovery found it by reflection. Assign the new permissions to a role at /admin/roles.
Ordering
If a slice must register before/after others, add [FeatureOrder(n)] to the endpoints class (default 100).
2. The frontend screen
Routes are files under src/pages/. Copy the template:
cp -r netforge.client/src/pages/_template "netforge.client/src/pages/(app)/projects"Rename, edit meta.ts ({ title, permissions }), and hand-author a typed API module at src/lib/api/projects.ts mirroring the existing ones (interfaces + a projectsApi object over the shared api client). Routes regenerate automatically — there's no route config to touch.
Next
- Put it on a DataGrid.
- Make it searchable in ⌘K.
- Test it.