The Attachments module contains 2 main components:
All EntityAttachments are linked to the same Attachment.
All attachments for all entities are stored in one table.
public interface IAttachment : IBinaryFile, IHasTimestamps;
public interface IAttachment<TKey> : IAttachment, IEntity<TKey>;
The Attachment is based on IBinaryFile (Part of Regira.IO module):
string? FileName - The name of a file (not full path)string? Identifier - Identifier in a specific context (Prefix + Filename)string? Prefix - The folder structure, except the root folderstring? Path - The full path/Uri for this filestring? ContentType - MIME type of the filelong Length - Size of the file in bytesbyte[]? Bytes - Content as a byte arrayStream? Stream - Content as a streamThe AttachmentFileService handles the physical file storage and retrieval for attachments.
public class AttachmentFileService<TAttachment, TKey>(IFileService fileService) : IAttachmentFileService<TAttachment, TKey>
{
public async Task<byte[]?> GetBytes(TAttachment item, CancellationToken token = default)
public async Task SaveFile(TAttachment item, CancellationToken token = default)
public async Task RemoveFile(TAttachment item, CancellationToken token = default)
public string GetIdentifier(string fileName)
public string GetRelativeFolder(TAttachment item)
}
It uses an underlying IFileService to perform the actual file operations.
Useful IFileService implementations:
BinaryFileService: Local File SystemBinaryBlobService: Azure Blob StorageSftpService: SFTP/SSH// (simplified)
public interface IEntityAttachment<TKey, TObjectKey> : IEntityAttachment<TKey, TObjectKey, int, Attachment>;
public interface IEntityAttachment<TKey, TObjectKey, TAttachmentKey> : IEntityAttachment<TKey, TObjectKey, TAttachmentKey, Attachment<TAttachmentKey>>;
public interface IEntityAttachment<TKey, TObjectKey, TAttachmentKey, TAttachment> : IEntity<TKey>, IHasObjectId<TObjectKey>, IEntityAttachment, ISortable
where TAttachment : class, IAttachment<TAttachmentKey>, new()
{
string? ObjectType { get; } // Name of owning entity type (e.g. Product, Article, ...)
// properties used to update existing attachment values
string? NewFileName { get; set; }
string? NewContentType { get; set; }
byte[]? NewBytes { get; set; }
TAttachmentKey AttachmentId { get; set; }
new TAttachment? Attachment { get; set; }
}
Inherit the EntityAttachment base (which maps to EntityAttachment<int, int, int, Attachment>) and
set ObjectType in the constructor.
public class ProductAttachment : EntityAttachment
{
public ProductAttachment() => ObjectType = nameof(Product);
}
Each owning entity gets its own subclass: the class is the join table and its constructor pins one
ObjectType, so attaching files to a second entity means a second subclass, DbSet, controller and
registration.
After defining the model of the EntityAttachment, 2 interfaces have to be implemented on the Owning Entity:
IHasAttachmentsIHasAttachments<TEntityAttachment>// other properties and interfaces are omitted
public class OwningEntity: IHasAttachments, IHasAttachments<MyEntityAttachment>
{
// ...
// Add these 3 properties
// HasAttachment is yours to fill: nothing populates it, so it serializes as null even for a row that
// has attachments. Filtering on it is also yours to wire — Regira.Entities.EFcore ships the
// FilterHasAttachment(bool?) query extension, but no query builder calls it for you.
public bool? HasAttachment { get; set; }
public ICollection<MyEntityAttachment>? Attachments { get; set; }
// implicit interface implementation
ICollection<IEntityAttachment>? IHasAttachments.Attachments
{
get => Attachments?.Cast<IEntityAttachment>().ToArray();
set => Attachments = value?.Cast<MyEntityAttachment>().ToArray();
}
}
// Add a DbSet for each EntityAttachment type
public DbSet<MyEntityAttachment> MyEntityAttachments { get; set; } = null!;
// Update OnModelCreating
modelBuilder.Entity<OwningEntity>(entity =>
{
entity.HasMany(e => e.Attachments)
.WithOne()
.HasForeignKey(e => e.ObjectId)
.HasPrincipalKey(e => e.Id);
});
The custom EntityAttachmentController must derive from EntityAttachmentControllerBase. Set the class
[Route] to the owner base path — the base actions append the sub-routes
({objectId}/attachments, attachments/{id}, {objectId}/files, files/{id}, …).
// using default DTOs (EntityAttachmentDto & EntityAttachmentInputDto))
[ApiController, Route("products")]
public class ProductAttachmentsController : EntityAttachmentControllerBase<ProductAttachment>;
// or using custom DTOs
[ApiController, Route("products")]
public class ProductAttachmentsController : EntityAttachmentControllerBase<ProductAttachment, MyAttachmentDto, MyAttachmentInputDto>;
Endpoints exposed (with [Route("products")]):
| Method | Route | Purpose |
|---|---|---|
POST |
{objectId}/files |
Upload a file (multipart IFormFile + input model) |
PUT |
{objectId}/files/{id} |
Replace an existing file |
GET |
{objectId}/attachments |
List attachments for an owner |
GET |
attachments/{id} |
Attachment metadata |
PUT |
{objectId}/attachments/{id} |
Update attachment metadata |
DELETE |
attachments/{id} |
Delete (also removes the file) |
GET |
files/{id} · {objectId}/files/{fileName} |
Download the file |
Attachments need two registrations:
WithAttachments(factory) registers the shared Attachment entity, the file store and the
bytes→file primer.HasAttachments<…>(x => x.Attachments) — chained on the owner’s For<>() builder — registers the
typed per-owner read/write services, the link prepper and DTO mapping.builder.Services
.AddHttpContextAccessor() // required for attachment Uri resolution
.UseEntities<MyDbContext>(o =>
{
o.UseDefaults();
o.UseAttachmentUris(); // web apps: resolve attachment DTO Uri's (ASP.NET Core)
/* ... */
})
// 1. shared Attachment entity + file store + bytes→file primer
.WithAttachments(_ => new BinaryFileService(
new FileSystemOptions
{
RootFolder = ApiConfiguration.AttachmentsDirectory
}
))
// 2. typed per-owner services + link prepper + DTO mapping
.For<Product>(e => e.HasAttachments<MyDbContext, Product, ProductAttachment>(x => x.Attachments));
Mapped owner (
UseMapping)? Declare the collection on the owner’s input DTO —public ICollection<EntityAttachmentInputDto>? Attachments { get; set; }— and mirror it on the read DTO withICollection<EntityAttachmentDto>?. Without the input property, the convention map yields anullcollection on every parent save, which the sync reads as “attachments not sent”: adds, removes and reorders through the parent are silently ignored while the/{objectId}/attachmentssub-routes keep working. Startup validation warns about this shape.
File-service factory.
WithAttachmentstakes anIFileServicefactory (Func<IServiceProvider, IFileService>), not a registeredIFileService— so your app can still register its own store(s) elsewhere. Build one inline (WithAttachments(_ => new BinaryFileService(...))) or reuse an app-registered one (WithAttachments(p => p.GetRequiredService<IFileService>())). It’s wrapped into the registeredIAttachmentFileService<Attachment, int>— one per attachment base type, so each can use a different store.
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.
Ordering. Attachment order travels by array position:
HasAttachmentswiresSetSortOrder()over the incoming collection, so every parent save assignsSortOrder = index— the input DTO carries no sort field on purpose, and any client-sent value is overwritten. The read DTO exposesSortOrder; order the eager-load (x.Attachments!.OrderBy(a => a.SortOrder)) so a round-trip is stable.
o.UseAttachmentUris()(web apps). Populates the attachment DTOUri.Entities.DependencyInjectiondoesn’t referenceEntities.Web, so the ASP.NET Core resolver (LinkGenerator+IHttpContextAccessor) is opt-in (namespaceRegira.Entities.Web.Attachments.DependencyInjection). Call it in theUseEntitiesoptions block, before entities are registered; without it,Uriisnull. TheUriis generated as a link to theGetFileaction on the attachment entity’s controller ({EntityAttachment}Controller : EntityAttachmentControllerBase<…>), so that controller must be mapped. If you replace the generated attachment endpoints with a custom download route, the link generator finds no matching action andUristaysnull— use the download endpoint directly. It is alsonulloutside an active request (e.g. during seeding).