This guide demonstrates the Regira Entities framework using a simple webshop scenario with Products and Categories.
public class Product : IEntity<int>, IHasTimestamps, IArchivable, IHasTitle, IHasDescription
{
public int Id { get; set; }
public string Title { get; set; } = null!;
public string? Description { get; set; }
public decimal Price { get; set; }
public int CategoryId { get; set; }
// Normalization support
[Normalized(SourceProperties = [nameof(Title), nameof(Description)])]
public string? NormalizedContent { get; set; }
// Built-in interfaces
public DateTime Created { get; set; }
public DateTime? LastModified { get; set; }
public bool IsArchived { get; set; }
// Navigation
public Category? Category { get; set; }
}
SearchObject<TKey> (and its SearchObject shorthand for int keys) is a record, so subclasses must also be records.
public record ProductSearchObject : SearchObject
{
public int? CategoryId { get; set; }
public ICollection<int>? CategoryIds { get; set; }
public decimal? MinPrice { get; set; }
public decimal? MaxPrice { get; set; }
}
public enum ProductSortBy
{
Default = 0,
Title,
TitleDesc,
Price,
PriceDesc,
Created,
CreatedDesc
}
[Flags]
public enum ProductIncludes
{
Default = 0,
Category = 1 << 0,
All = Category
}
public class ProductQueryBuilder : FilteredQueryBuilderBase<Product, int, ProductSearchObject>
{
public override IQueryable<Product> Build(IQueryable<Product> query, ProductSearchObject? so)
{
if (so == null) return query;
// Filter by CategoryId
if (so.CategoryId.HasValue)
query = query.Where(x => x.CategoryId == so.CategoryId.Value);
// Filter by CategoryIds
if (so.CategoryIds?.Any() == true)
query = query.Where(x => so.CategoryIds.Contains(x.CategoryId));
// Price range
if (so.MinPrice.HasValue)
query = query.Where(x => x.Price >= so.MinPrice.Value);
if (so.MaxPrice.HasValue)
query = query.Where(x => x.Price <= so.MaxPrice.Value);
return query;
}
}
public class ProductDto
{
public int Id { get; set; }
public string Title { get; set; } = null!;
public string? Description { get; set; }
public decimal Price { get; set; }
public int CategoryId { get; set; }
public string? CategoryTitle { get; set; }
public DateTime Created { get; set; }
public DateTime? LastModified { get; set; }
}
public class ProductInputDto
{
[Required, MaxLength(200)]
public string Title { get; set; } = null!;
[MaxLength(1000)]
public string? Description { get; set; }
[Range(0, 999999)]
public decimal Price { get; set; }
public int CategoryId { get; set; }
}
[ApiController]
[Route("[controller]")]
public class ProductsController : EntityControllerBase<Product, ProductSearchObject, ProductSortBy, ProductIncludes, ProductDto, ProductInputDto>
{
}
services.UseEntities<ShopDbContext>(options =>
{
options.AddDefaultEntityNormalizer();
})
.For<Product, ProductSearchObject, ProductSortBy, ProductIncludes>(e =>
{
e.AddFilter<ProductQueryBuilder>()
.UseMapping<ProductDto, ProductInputDto>()
.After((product, dto) =>
{
// AfterMapper: Add category title to DTO
dto.CategoryTitle = product.Category?.Title;
});
});
public class Category : IEntity<int>, IHasTitle
{
public int Id { get; set; }
public string Title { get; set; } = null!;
public ICollection<Product>? Products { get; set; }
}
public record CategorySearchObject : SearchObject
{
// Uses default SearchObject properties only
}
public class CategoryDto
{
public int Id { get; set; }
public string Title { get; set; } = null!;
public int ProductCount { get; set; }
}
public class CategoryInputDto
{
[Required, MaxLength(100)]
public string Title { get; set; } = null!;
}
[ApiController]
[Route("[controller]")]
public class CategoriesController : EntityControllerBase<Category, CategoryDto, CategoryInputDto>
{
}
services.UseEntities<ShopDbContext>(options => { /* ... */ })
.For<Category>(e =>
{
// Inline QueryBuilder
e.Filter((query, so) =>
{
// Title search using Q property
if (!string.IsNullOrWhiteSpace(so?.Q))
query = query.Where(x => EF.Functions.Like(x.Title, $"%{so.Q}%"));
return query;
})
.UseMapping<CategoryDto, CategoryInputDto>()
// Inline AfterMapper
.After((category, dto) =>
{
dto.ProductCount = category.Products?.Count ?? 0;
});
});
// Inherit the `EntityAttachment` base (= EntityAttachment<int,int,int,Attachment>) and set ObjectType
// in the constructor.
public class ProductAttachment : EntityAttachment
{
public ProductAttachment() => ObjectType = nameof(Product);
}
public class Product : IEntity<int>, IHasTimestamps, IArchivable, IHasTitle, IHasDescription,
IHasAttachments, IHasAttachments<ProductAttachment>
{
// ... existing properties ...
// Attachment support
public bool? HasAttachment { get; set; }
public ICollection<ProductAttachment>? Attachments { get; set; }
ICollection<IEntityAttachment>? IHasAttachments.Attachments
{
get => Attachments?.Cast<IEntityAttachment>().ToArray();
set => Attachments = value?.Cast<ProductAttachment>().ToArray();
}
}
public class ShopDbContext : DbContext
{
public DbSet<Product> Products { get; set; }
public DbSet<Category> Categories { get; set; }
public DbSet<Attachment> Attachments { get; set; }
public DbSet<ProductAttachment> ProductAttachments { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.SetDecimalPrecisionConvention(18, 2);
modelBuilder.Entity<ProductAttachment>()
.HasOne(x => x.Attachment)
.WithMany()
.HasForeignKey(x => x.AttachmentId);
// Product is IArchivable — nothing to add: UseEntities<TContext>(e => e.UseDefaults())
// wires the archived query filter into the context's options
}
}
Both entity and attachment endpoints require a controller:
[ApiController]
[Route("[controller]")]
public class ProductsController : EntityControllerBase<Product, ProductSearchObject, ProductSortBy, ProductIncludes, ProductDto, ProductInputDto>
{
}
// The class route is the owner base path; the base actions append the sub-routes
// {objectId}/attachments, attachments/{id}, {objectId}/files, files/{id}, ...
[ApiController]
[Route("products")]
public class ProductAttachmentsController : EntityAttachmentControllerBase<ProductAttachment>
{
}
Attachments need two registrations: WithAttachments(factory) registers the shared Attachment
entity, the file store and the bytes→file primer (framework infrastructure — no license slot), and
HasAttachments<…>(x => x.Attachments) — chained on the owner’s For<>() builder — registers the typed
per-owner services, the link prepper and DTO mapping (one simple-tier slot — the per-owner join entity).
// only the provider — UseEntities(options => options.UseDefaults()) below auto-wires the
// interceptors and the UTC date convention
services.AddDbContext<ShopDbContext>(db =>
{
db.UseSqlServer(connectionString);
});
services
.AddHttpContextAccessor() // web apps: required for attachment Uri resolution
.UseEntities<ShopDbContext>(options =>
{
options.UseDefaults();
options.UseAttachmentUris(); // web apps: resolve attachment DTO Uri's (opt-in)
/* ... */
})
// 1. shared Attachment entity + file store + bytes→file primer (framework infrastructure — no license slot)
.WithAttachments(sp => new BinaryFileService(
new FileSystemOptions { RootFolder = "uploads/products" }))
// 2. typed per-owner services + link prepper + DTO mapping
.For<Product, ProductSearchObject, ProductSortBy, ProductIncludes>(e =>
e.HasAttachments<ShopDbContext, Product, ProductAttachment>(x => x.Attachments));
Reading file bytes: use the built-in download endpoints, or inject
IAttachmentFileService<Attachment, int>and callGetBytes(item). Consuming code references files byIdentifier(the public storage key, populated when you load through the entity service);Pathis internal and isn’t mapped to DTOs — clients get a downloadUriinstead.
public class ProductNormalizer : EntityNormalizerBase<Product>
{
private readonly INormalizer _normalizer;
public ProductNormalizer(INormalizer normalizer)
{
_normalizer = normalizer;
}
public override async Task HandleNormalize(Product item, CancellationToken token = default)
{
var content = $"{item.Title} {item.Description}".Trim();
item.NormalizedContent = await _normalizer.Normalize(content);
}
}
services.UseEntities<ShopDbContext>(options => { /* ... */ })
.For<Product>(e =>
{
e.AddNormalizer<ProductNormalizer>();
// ... rest of configuration ...
});