Your first feature¶
This page builds the brands of the template from scratch: an entity, its table, permissions, a command with validation, an HTTP endpoint, an OData set with facets and a Blazor page. Every snippet is the real code of the template.
1. Contracts¶
Permission names and DTOs live in the contracts project, because the Blazor client needs them too.
public static class CatalogPermissions
{
public const string GroupName = "Catalog";
public static class Brands
{
public const string View = "Catalog.Brands.View";
public const string Create = "Catalog.Brands.Create";
public const string Edit = "Catalog.Brands.Edit";
public const string Delete = "Catalog.Brands.Delete";
}
}
public sealed record BrandDto(Guid Id, string Name, string? Description, decimal Tax);
public sealed class AddEditBrandRequest
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public decimal Tax { get; set; }
}
The request is a class with setters on purpose: the edit dialog binds to it.
2. Entity and table¶
[Realtime(CatalogPermissions.Brands.View)]
public sealed class Brand : AuditedEntity, IMultiTenant
{
public required string Name { get; set; }
public string? Description { get; set; }
public decimal Tax { get; set; }
public Guid TenantId { get; set; }
}
AuditedEntitybringsId(a version 7 GUID),CreatedAt,CreatedBy,ModifiedAt,ModifiedBy. The values are set on save.IMultiTenantadds a query filter on the tenant of the current user and fillsTenantIdon insert.[Realtime]publishes every change of a brand to clients that hold the permission; tables ofBrandDtoreload by themselves, see Realtime.
Modules do not own a DbContext. They contribute to the one of the app:
internal sealed class CatalogModelContributor : IModelContributor
{
public void Apply(ModelBuilder modelBuilder) =>
modelBuilder.Entity<Brand>(brand =>
{
brand.ToTable("Brands", "app");
brand.Property(b => b.Name).HasMaxLength(200);
brand.Property(b => b.Tax).HasPrecision(9, 4);
brand.HasIndex(b => new { b.TenantId, b.Name }).IsUnique();
});
}
Add a migration in the infrastructure project:
3. Permissions¶
internal sealed class CatalogPermissionDefinitions : IPermissionDefinitionContributor
{
public void Define(PermissionDefinitionContext context) =>
context.Group(CatalogPermissions.GroupName, "Catalog")
.Add(CatalogPermissions.Brands.View, "View brands")
.Add(CatalogPermissions.Brands.Create, "Create brands", CatalogPermissions.Brands.View)
.Add(CatalogPermissions.Brands.Edit, "Edit brands", CatalogPermissions.Brands.View)
.Add(CatalogPermissions.Brands.Delete, "Delete brands", CatalogPermissions.Brands.View);
}
The third argument is the parent: granting Create brands in the role editor implies View brands.
4. The command¶
A use case is a request, a handler and, when there is input, a validator.
public sealed record AddEditBrandCommand(Guid? Id, AddEditBrandRequest Brand) : ICommand<Result<BrandDto>>, IInvalidatesCache
{
public IReadOnlyList<string> CacheTags => [DashboardCache.Tag];
}
internal sealed class AddEditBrandValidator : AbstractValidator<AddEditBrandCommand>
{
public AddEditBrandValidator()
{
RuleFor(c => c.Brand.Name).NotEmpty().MaximumLength(200);
RuleFor(c => c.Brand.Tax).InclusiveBetween(0, 100);
}
}
internal sealed class AddEditBrandHandler(CoworkeeDbContext db, IPermissionChecker permissions) : IHandler<AddEditBrandCommand, Result<BrandDto>>
{
public async Task<Result<BrandDto>> HandleAsync(AddEditBrandCommand command, CancellationToken cancellationToken)
{
var required = command.Id is null ? CatalogPermissions.Brands.Create : CatalogPermissions.Brands.Edit;
if (!await permissions.IsGrantedAsync(required, cancellationToken))
{
return Error.Forbidden("catalog.forbidden", "You may not do this.");
}
var name = command.Brand.Name.Trim();
if (await db.Set<Brand>().AnyAsync(b => b.Name == name && b.Id != command.Id, cancellationToken))
{
return BrandErrors.NameTaken;
}
var brand = command.Id is { } id ? await db.Set<Brand>().SingleOrDefaultAsync(b => b.Id == id, cancellationToken) : Add(name);
if (brand is null)
{
return BrandErrors.NotFound;
}
brand.Name = name;
brand.Description = command.Brand.Description;
brand.Tax = command.Brand.Tax;
return brand.ToDto();
}
private Brand Add(string name)
{
var brand = new Brand { Name = name };
db.Add(brand);
return brand;
}
}
Note what the handler does not do: it does not call SaveChangesAsync, catch validation errors or write logs. The pipeline does that around every request. When a request needs a single permission, an attribute is enough: [RequiresPermission(CatalogPermissions.Brands.Delete)] on the delete command.
5. Endpoint and OData set¶
internal static class BrandEndpoints
{
public static void MapBrandEndpoints(this IEndpointRouteBuilder app)
{
var brands = app.MapCoworkeeApi("/api/v1/brands").WithTags("Brands").RequireAuthorization();
brands.MapPost("/", (AddEditBrandRequest body, IDispatcher d, CancellationToken ct) => d.SendAsync(new AddEditBrandCommand(null, body), ct).ToHttpResult());
brands.MapPut("/{id:guid}", (Guid id, AddEditBrandRequest body, IDispatcher d, CancellationToken ct) => d.SendAsync(new AddEditBrandCommand(id, body), ct).ToHttpResult());
brands.MapPost("/delete", (IdsRequest body, IDispatcher d, CancellationToken ct) => d.SendAsync(new DeleteBrandsCommand(body.Ids), ct).ToHttpResult());
}
}
ToHttpResult turns a Result into 200, 204 or a problem details response with the right status (400 for validation, 403, 404, 409).
Reading needs no endpoint. Register the entity as an OData set and every table gets paging, sorting, filtering, search, export and facets:
[DependsOn(typeof(CoworkeeODataModule))]
public sealed class MyAppCatalogModule : CoworkeeModule, IWebModule
{
public override void ConfigureServices(ModuleServiceContext context)
{
context.Services.AddMessagingFromAssembly(typeof(MyAppCatalogModule).Assembly);
context.Services.AddSingleton<IModelContributor, CatalogModelContributor>();
context.Services.AddSingleton<IPermissionDefinitionContributor, CatalogPermissionDefinitions>();
context.Services.AddODataEntity<Brand>("Brands", CatalogPermissions.Brands.View);
}
public void ConfigureApplication(WebApplication app) => app.MapBrandEndpoints();
}
GET /odata/Brands?$filter=contains(Name,'a')&$orderby=Name&$count=true now works for everyone with View brands.
6. The Blazor page¶
@page "/catalog/brands"
@attribute [Authorize(Policy = PermissionPolicy.Prefix + CatalogPermissions.Brands.View)]
<PageHeader Title="Brands" Description="Manage brands." />
<CoworkeeDataTable @ref="_table" T="BrandDto" EntitySet="Brands" SearchFields="SearchFields" MultiSelection="true" DescribeItem="b => b.Name"
CreatePermission="@CatalogPermissions.Brands.Create" EditPermission="@CatalogPermissions.Brands.Edit" DeletePermission="@CatalogPermissions.Brands.Delete"
OnCreate="CreateAsync" OnEdit="EditAsync" OnDelete="DeleteAsync">
<Columns>
<PropertyColumn Property="b => b.Name" />
<PropertyColumn Property="b => b.Description" />
<PropertyColumn Property="b => b.Tax" Format="0.##" />
</Columns>
</CoworkeeDataTable>
public partial class Brands
{
private static readonly string[] SearchFields = [nameof(BrandDto.Name), nameof(BrandDto.Description)];
private CoworkeeDataTable<BrandDto> _table = null!;
[Inject] private ICatalogApi Api { get; set; } = null!;
[Inject] private IDialogService Dialogs { get; set; } = null!;
[Inject] private ISnackbar Snackbar { get; set; } = null!;
private Task CreateAsync() => EditAsync(null, new AddEditBrandRequest());
private Task EditAsync(BrandDto brand) =>
EditAsync(brand.Id, new AddEditBrandRequest { Name = brand.Name, Description = brand.Description, Tax = brand.Tax });
private async Task EditAsync(Guid? id, AddEditBrandRequest model)
{
if (await Dialogs.ShowEditAsync(id is null ? "New brand" : "Edit brand", model) is { } saved)
{
await Snackbar.RunAsync(() => Api.SaveBrandAsync(id, saved), "Brand saved");
}
}
private Task DeleteAsync(IReadOnlyCollection<BrandDto> brands) => Snackbar.RunAsync(() => Api.DeleteBrandsAsync([.. brands.Select(b => b.Id)]));
}

Buttons the user lacks the permission for are not rendered. ShowEditAsync builds the form from the request type with MudBlazor.Extensions; for anything special, write your own dialog like ProductDialog in the template.
Last step, the navigation entry:
internal sealed class MyAppNavigation : INavigationContributor
{
public IEnumerable<CoworkeeNavItem> Items =>
[
new("Brands", "/catalog/brands", Icons.Material.Outlined.Sell, CatalogPermissions.Brands.View, Group: "Catalog Management"),
];
}
What you did not write¶
Logging, transactions, validation responses, permission checks for the list, paging and filtering on the server, CSV export, the delete confirmation, live reload when someone else changes a brand, the audit trail. They come from the packages.
Next steps¶
- Application services: instead of mapping endpoints and writing an API client, declare an interface in the contracts; the server endpoints and the Blazor client are generated. The Sales Orders of the template are built that way.
- Client entities: mark an entity
[ClientEntity]and the browser keeps its rows, so the table filters, sorts and groups without a round trip. The template does it with 100,000 sales orders.