DKNet

DKNet.EfCore.Abstractions

The shared, persistence-agnostic vocabulary every other DKNet.EfCore.* package builds on — entity base classes, domain-event contracts, and the attributes that steer audit, sequence, and mapping behaviour.

✨ Why use it?

Reach for this package first when modelling a new domain entity in a DKNet-based solution.

🚀 Quick Start

dotnet add package DKNet.EfCore.Abstractions

There is no DI registration for this package — it is pure types (base classes, interfaces, attributes) referenced at compile time by your domain project and by the DKNet packages that implement runtime behavior against them. Minimum usage is simply deriving your entity from Entity (or Entity<TKey>):

using DKNet.EfCore.Abstractions.Entities;

public class Product : Entity // Entity<Guid>
{
    private Product() { } // EF Core

    public Product(string name, decimal price) : base(Guid.NewGuid())
    {
        Name = name;
        Price = price;
    }

    public string Name { get; private set; } = null!;
    public decimal Price { get; private set; }
}

Runtime behavior — actually dispatching events, writing audit logs, redacting sensitive fields, enforcing row ownership, encrypting columns — comes from the sibling packages described in Where it fits below; this package only supplies the shapes they agree on.

🧩 Features

Entity identity — IEntity<TKey>, Entity<TKey> / Entity

IEntity<out TKey> is the minimal contract: a single TKey Id { get; }. Entity<TKey> is the base class you actually derive from — it implements IEntity<TKey> and IEventEntity (see below), exposes Id with a private setter, and overrides ToString() as "{TypeName} '{Id}'". Entity is a convenience specialization with TKey = Guid, the recommended default for distributed systems (globally unique, no DB round-trip needed to generate).

public class Category : Entity<int>
{
    private Category() { }
    public Category(int id, string name) : base(id) => Name = name;
    public string Name { get; private set; } = null!;
}

Both constructors that take an id exist mainly for EF Core data-seeding scenarios (per their own XML docs) — day-to-day creation typically leaves EF Core / a value generator to assign Id.

Domain events queued on the entity — IEventEntity, Entity<TKey>

IEventEntity lets an entity queue up domain events during a business operation, to be drained and published once the surrounding SaveChanges succeeds:

Entity<TKey> already implements IEventEntity for you via two internal Collection<> fields, so every entity deriving from Entity/Entity<TKey> gets event-queuing for free:

public class Order : Entity
{
    public void Place()
    {
        // ... business logic ...
        AddEvent(new OrderPlacedEvent(Id));
    }
}

Queuing an event here does nothing by itself — see “Composition” for how DKNet.EfCore.Events drains and publishes the queue.

Declarative events — [RaisesEvent], EventOperations

[RaisesEvent] is an alternative (or complement) to hand-calling AddEvent(...): apply it to the entity class to declare that a persistence operation should raise a payload automatically, without touching the entity’s method bodies. It is repeatable (AllowMultiple = true) — apply once per event the entity raises.

Breaking change: the string-form argument used to be the generated record’s name verbatim. It is now only the optional label segment of a name composed by fixed convention — every existing string-form declaration produces a differently-named record after upgrading, and each one must be revisited. Before: [RaisesEvent("CustomerTouched", EventOperations.Created)] generated CustomerTouched. After, the same declaration generates CustomerTouchedCreatedEvent (entity name + label + operation + Event).

Three forms:

using DKNet.EfCore.Abstractions.Entities;
using DKNet.EfCore.Abstractions.Events;
using DKNet.EfCore.DtoGenerator;

// Type-naming form — names an existing [GenerateDto] payload record (DKNet.EfCore.DtoGenerator)
[GenerateDto(typeof(Order), Exclude = new[] { "InternalNote" })]
public partial record OrderPlacedEvent;

[RaisesEvent(typeof(OrderPlacedEvent), EventOperations.Created)]
[RaisesEvent(typeof(OrderStatusChangedEvent), EventOperations.Updated, nameof(Order.Status))]
public class Order : Entity
{
    public string Status { get; set; } = string.Empty;
}

// Label-less convention form — no hand-written payload record; the build generates and names it by convention
[RaisesEvent(EventOperations.Created)]
public class Customer : Entity
{
}
// generates CustomerCreatedEvent

// Label convention form — the label is composed into the generated name
[RaisesEvent("Touched", EventOperations.Created)]
public class Product : Entity
{
}
// generates ProductTouchedCreatedEvent

EventOperations is a [Flags] enum with Created = 1, Updated = 2, Deleted = 4 — combine flags (Created | Updated) to raise the same event type for more than one lifecycle transition. For Updated rules, the optional trailing properties (nameof(...)-checked) narrow the rule to fire only when at least one listed direct property changed; an empty list fires on any change. Nested/owned-value changes never satisfy the narrowing — only direct properties of the carrying entity are observed.

Convention-form naming

Both convention forms (label and label-less) name their generated record by a fixed, non-configurable formula — never a literal name — composed in this order:

  1. The carrying entity’s simple name.
  2. The label, when one is given (absent entirely for the label-less form).
  3. The narrowing properties, de-duplicated and sorted ordinally (culture-independent) — declaration order and machine locale never affect the result.
  4. The declared operations, always emitted in the canonical order Created, Updated, Deleted, regardless of the order the flags were combined in.
  5. The literal suffix Event.

For example, [RaisesEvent(EventOperations.Updated, nameof(Customer.Tier))] on Customer composes CustomerTierUpdatedEvent; [RaisesEvent(EventOperations.Created | EventOperations.Updated)] composes CustomerCreatedUpdatedEvent. A declaration with no operation named (Operations == 0) is a build error (DKRAISEVT007) — it can never raise anything. A composed name that isn’t a single valid C# identifier (e.g. a label containing whitespace or punctuation) is a build error (DKRAISEVT005) and generates no record. Two declarations composing to the identical name are always an error, never silently merged: on the same entity that’s DKRAISEVT008, across different entities in the same namespace it’s DKRAISEVT006.

Payload filters for composed records

Either convention form can also narrow the shape of its composed payload record with Exclude/Include named arguments — they never affect the composed name, only the record’s properties:

using DKNet.EfCore.Abstractions.Entities;
using DKNet.EfCore.Abstractions.Events;

[RaisesEvent(EventOperations.Created, Exclude = new[] { "InternalNote" })]
[RaisesEvent("Touched", EventOperations.Updated, Include = new[] { nameof(Customer.Name), nameof(Customer.Email) })]
public class Customer : Entity
{
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string InternalNote { get; set; } = string.Empty;
}

This attribute alone raises nothing at runtime: DKNet.EfCore.DtoGenerator validates the rule at build time (the named type must be a [GenerateDto] payload generated from the same entity for the type-naming form; the composed name must be a valid, non-colliding identifier for the convention forms), and DKNet.EfCore.Events’ save hook reads [RaisesEvent] via reflection to actually raise the payload after a successful save, composing the same name from the same EventNameComposer source the build uses — the two can never disagree. A project that references only DKNet.EfCore.Abstractions and DKNet.EfCore.DtoGenerator builds cleanly with rules declared and simply never raises them until the application also registers DKNet.EfCore.Events.

Audit tracking — IAuditedProperties, IAuditedEntity<TKey>, AuditedEntity<TKey> / AuditedEntity

IAuditedProperties declares the four audit fields every audited entity needs: CreatedOn, CreatedBy, UpdatedOn, UpdatedBy (all decorated [IgnoreAuditLog] on the interface itself — see feature 6). CreatedBy and UpdatedBy also carry [MaxLength(500)]. IAuditedEntity<TKey> combines IEntity<TKey> and IAuditedProperties into one contract.

This package declares the fields; it never fills them. Two hook packages do, and which one depends on whether a signed-in user is available for the save:

Source of the value Filled by When
Your own domain method (SetCreatedBy/SetUpdatedBy) the entity itself always wins — the hooks below never overwrite it
The signed-in user, via ICurrentUserProvider.GetCurrentUser() DKNet.EfCore.AuditLogs’ EfCoreAuditHook a provider is registered (AddCurrentUserProvider<TDbContext, TProvider>()) and returns a non-empty value for that save
The tenant ownership key, via IDataOwnerProvider.GetOwnershipKey() DKNet.EfCore.DataAuthorization’ DataOwnerHook no current-user value is available for that save — no provider registered, or one that returned null/empty

With neither hook registered, the four properties stay exactly as your code left them.

AuditedEntity<TKey> / AuditedEntity (Guid-keyed) is the base class you actually derive from. It implements the four properties with private setters plus two derived, [NotMapped] conveniences — LastModifiedBy (falls back to CreatedBy when never updated) and LastModifiedOn (falls back to CreatedOn) — and two protected mutators:

public class Invoice : AuditedEntity
{
    private Invoice() { }

    public static Invoice Create(string createdBy)
    {
        var invoice = new Invoice();
        invoice.SetCreatedBy(createdBy);       // no-ops if CreatedBy is already set
        return invoice;
    }

    public void MarkPaid(string updatedBy) => SetUpdatedBy(updatedBy);
}

Merely deriving from AuditedEntity does not populate these fields for you on every save — that stamping is performed by whichever hook or interceptor your application wires up (DKNet ships this behavior in DKNet.EfCore.AuditLogs and DKNet.EfCore.DataAuthorization, both running on the DKNet.EfCore.Hooks pipeline; they detect IAuditedProperties and stamp CreatedBy on Added and UpdatedBy on Modified entries, from the signed-in user or the ownership key per the table above — see “Composition”). Implementing IAuditedProperties is also the switch that turns an entity into a candidate for audit logging (feature 6) — an entity that doesn’t implement it is invisible to DKNet.EfCore.AuditLogs regardless of any attributes you put on it.

Optimistic concurrency — IConcurrencyEntity<TType>

Declares a nullable RowVersion (typed TType, e.g. byte[]), pre-annotated [Timestamp] and [Column(Order = 1000)] so the concurrency token consistently sorts last in generated schemas, plus SetRowVersion(TType rowVersion) to update it. Implement this on an entity to opt into EF Core’s row-version concurrency check:

public class Account : Entity, IConcurrencyEntity<byte[]>
{
    public byte[]? RowVersion { get; private set; }
    public void SetRowVersion(byte[] rowVersion) => RowVersion = rowVersion;
}

DKNet.EfCore.Extensions’ DefaultEntityTypeConfiguration<TEntity> detects IConcurrencyEntity<> by reflection and automatically configures the RowVersion property as a concurrency token / row-version column with ValueGeneratedOnAddOrUpdate() — you do not need to configure it yourself in OnModelCreating if you use that base configuration.

Soft deletion — ISoftDeletableEntity

Declares IsDeleted, DeletedOn, DeletedBy ([MaxLength(250)]) plus a Delete(byUser, deletedOn = null) method that returns FluentResults.IResultBase so implementers can fail the delete (e.g. business-rule violation) without throwing:

public class Document : Entity, ISoftDeletableEntity
{
    public bool IsDeleted { get; private set; }
    public DateTimeOffset? DeletedOn { get; private set; }
    public string? DeletedBy { get; private set; }

    public IResultBase Delete(string byUser, DateTimeOffset? deletedOn = null)
    {
        IsDeleted = true;
        DeletedBy = byUser;
        DeletedOn = deletedOn ?? DateTimeOffset.UtcNow;
        return Result.Ok();
    }
}

Gotcha: this is a contract only. No shipped DKNet package currently implements the query-filter or interceptor side (translating “call Delete” into “actually filter it out of default queries” or “turn a hard delete into a soft one automatically”). Implementing ISoftDeletableEntity gives you a consistent shape to code your own global query filter (modelBuilder.Entity<T>().HasQueryFilter(e => !e.IsDeleted)) against — it does not wire one up for you.

Sequential values — [Sequence], [SqlSequence]

The two sequence attributes are used together: [SqlSequence] goes on the enum that names your sequences and fixes their database schema; [Sequence] goes on each enum field and describes that one sequence.

using DKNet.EfCore.Abstractions.Attributes;

[SqlSequence("billing")]           // schema for every sequence in this enum; defaults to "seq"
public enum InvoiceSequences
{
    [Sequence(typeof(long), StartAt = 1000, IncrementsBy = 1, FormatString = "INV-{1:00000000}")]
    InvoiceNumber,

    [Sequence]                     // int, all database defaults
    CreditNoteNumber
}

FormatString has a fixed two-placeholder contract, applied by DKNet.EfCore.Extensions’ NextSeqValueWithFormat: {1} is the sequence value, and the literal token DateTime is rewritten to {0} and bound to DateTime.UtcNow — so "T{DateTime:yyMMdd}{1:00000}" renders as T26090100042. A format string that uses {0} directly gets the timestamp, not the number.

[Sequence] targets AttributeTargets.Field — it goes on the enum member, not on an entity property. The constructor’s optional Type defaults to int and only byte, short, int and long are accepted; anything else throws NotSupportedException while the attribute is being constructed. The four numeric options (StartAt, IncrementsBy, Min, Max) all default to -1, which DKNet.EfCore.Extensions reads as “leave it to the database” — only values greater than zero are applied. Cyclic is always applied, defaulting to true. Full property list: Configuration reference.

DKNet.EfCore.Extensions registers these against the model during UseAutoConfigModel, and only when the provider is SQL Server or Npgsql. Each sequence is named Seq_{enumMemberName} in the [SqlSequence] schema, and an enum member without its own [Sequence] is skipped. Read a value back with dbContext.NextSeqValue(InvoiceSequences.InvoiceNumber), or NextSeqValueWithFormat(...) to get FormatString applied.

Audit-log opt-in and redaction — [AuditLog], [IgnoreAuditLog], [SensitiveDataAttribute]

These three attributes are pure markers read by DKNet.EfCore.AuditLogs (there is zero behavior in this package itself) but they only make sense in terms of that consumer, so they’re covered together:

[AuditLog] // only needed under OnlyAttributedAuditedEntities behaviour
public class Customer : AuditedEntity
{
    public string Email { get; private set; } = null!;

    [SensitiveDataAttribute] // always redacted in the audit log
    public string NationalId { get; private set; } = null!;
}

Requires IAuditedProperties. Verified against DKNet.EfCore.AuditLogs’ AuditLogExtensions.BuildAuditLog: an entity that doesn’t implement IAuditedProperties is skipped before any of these attributes are even inspected — attaching [AuditLog] to a plain Entity does nothing.

Declaring a property sensitive — [SensitiveData]

[SensitiveData] is one declaration on the domain property, read by two independent consumers:

  1. Audit-log redaction (DKNet.EfCore.AuditLogs) — the value is replaced with "***REDACTED***" in the captured audit entry. This is the original behaviour and it is unchanged.
  2. Role-gated API response filtering (DKNet.EfCore.Extensions) — the property is omitted from the serialized JSON unless the caller is authenticated and holds one of the roles the declaration names. This consumer is opt-in per host; see DKNet.EfCore.Extensions.

The attribute takes an optional params string[] roles:

using DKNet.EfCore.Abstractions.Attributes;
using DKNet.EfCore.Abstractions.Entities;

public class Product : AuditedEntity
{
    public string Name { get; private set; } = null!;
    public decimal Price { get; private set; }

    [SensitiveData("pricing")]              // only callers in the "pricing" role
    public decimal SupplierCostPrice { get; private set; }

    [SensitiveData("pricing", "audit")]     // either role is enough
    public decimal SupplierRebate { get; private set; }

    [SensitiveData]                         // any authenticated caller
    public string SupplierReferenceCode { get; private set; } = null!;
}
Declaration Roles Who receives the property (when a host has opted in)
[SensitiveData] empty Any authenticated caller. Naming no role does not mean “everyone” — an unauthenticated or unknown caller is still refused.
[SensitiveData("pricing")] ["pricing"] An authenticated caller for whom IsInRole("pricing") is true.
[SensitiveData("pricing", "audit")] ["pricing", "audit"] An authenticated caller in at least one of the named roles.

Roles is never null — it is an empty IReadOnlyList<string> when no role is named. Role names are matched by ClaimsPrincipal.IsInRole, whose comparison is the one your identity stack configures (ordinal by default), so write the role names exactly as they appear in the caller’s claims.

Nothing here reaches the API on its own: this package defines the attribute, DKNet.EfCore.DtoGenerator carries it onto the generated response model, and DKNet.EfCore.Extensions is where a host turns the filtering on.

Excluding a class from automatic mapping — [IgnoreEntity]

A class-level marker documented as excluding a type “from the automatic entity mapper”, intended for delivered (non-EF-mapped) types. Verified caveat: as of this writing, no shipped DKNet package (Extensions, DtoGenerator, etc.) actually inspects this attribute — its only current references in the solution are its own definition and its own unit tests asserting attribute metadata (AttributeUsage, sealed, etc.). Treat it as a declared-but-not-yet-wired extension point rather than something that changes runtime EF Core discovery today; don’t rely on it to keep a type out of your model until you’ve confirmed the specific mapper/generator you’re using reads it.

Vertical-slice CRUD markers — [CrudCreate], [CrudUpdate], [CrudAction]

Three method/constructor markers that declare which members of an entity become generated HTTP endpoints. They are declared here but consumed entirely by DKNet.SlimBus.Generators, which emits the request records, handlers, and minimal-API registration from them — this package contributes only the attribute types.

using DKNet.EfCore.Abstractions.Attributes;
using DKNet.EfCore.Abstractions.Entities;

public class Order : Entity
{
    [CrudCreate]                                   // one per entity; ctor or method
    public Order(string customer) => Customer = customer;

    [CrudUpdate]                                   // any public instance method
    public void Rename(string customer) => Customer = customer;

    [CrudAction("approval", Verb = CrudActionVerb.Patch)]
    public void Approve(string approvedBy) => ApprovedBy = approvedBy;

    public string Customer { get; private set; }
    public string? ApprovedBy { get; private set; }
}

All three expose a Name property that overrides the generated request type’s name. Marking one member with both [CrudUpdate] and [CrudAction] is a build error. Property tables: Configuration reference; the generated code, routes, and full diagnostics list live in DKNet.SlimBus.Generators.

Publishing contract — IEventPublisher, DefaultEventPublisher, IEventItem / EventItem

IEventPublisher is the sink domain events are handed to: PublishAsync(object, CancellationToken) for one event, PublishAsync(IEnumerable<object>, CancellationToken) for a batch. DefaultEventPublisher is an abstract base that implements the batch overload as a sequential foreach over the single-event abstract method — you only override PublishAsync(object, ...) to get a working batch implementation. Register one or more IEventPublisher implementations in DI; DKNet.EfCore.Events fans every dispatched event out to all of them.

IEventItem is an optional shape for the event payload itself: AdditionalData ([JsonIgnore], an IDictionary<string,string> meant for message-header routing/filtering data that should not appear in the serialized event body) and EventType (a string type-tag). EventItem is the matching abstract record base, defaulting AdditionalData to an ordinal-case-insensitive dictionary and EventType to GetType().FullName. DKNet.EfCore.Events’ dispatcher stamps AdditionalData[nameof(sourceType)] (i.e. key "sourceType") with the originating entity’s full type name for every dispatched event that implements IEventItem, whether or not you derive from EventItem yourself.

⚙️ Configuration reference

There is no options object and no DI registration in this package — its entire customisation surface is the attributes below plus the interfaces you implement. Every default is taken from the attribute’s own source.

RaisesEventAttribute (DKNet.EfCore.Abstractions.Events)

[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] — repeatable, one per event the entity raises, and never inherited by a derived entity.

Three constructor forms, exactly one of which each declaration uses:

Form Signature Names the payload
Type-naming (Type eventType, EventOperations operations, params string[] properties) You do — it must be a [GenerateDto] record generated from the same entity.
Label convention (string label, EventOperations operations, params string[] properties) Composed: entity + label + properties + operations + Event.
Label-less convention (EventOperations operations, params string[] properties) Composed: entity + properties + operations + Event.
Member Type Default Effect
EventType Type? null Set only by the type-naming form; the payload record to raise.
Label string? null Set only by the label form; one extra segment in the composed name. Takes no part in the payload’s shape.
Operations EventOperations required, no default Created = 1, Updated = 2, Deleted = 4, combinable. 0 is a build error (DKRAISEVT007).
Properties IReadOnlyList<string> (trailing params) empty Narrows an Updated rule to fire only when a listed property changed. Empty means any change qualifies. Direct properties only.
Exclude string[] [] Convention forms only. Properties to drop from the composed payload. Mutually exclusive with Include (DKRAISEVT009); a build error on the type-naming form (DKRAISEVT011).
Include string[] [] Convention forms only. When non-empty, the whole truth for the composed payload’s shape, overriding the project-wide DtoGeneratorExclusions. Same exclusivity and same type-form restriction as Exclude.

SequenceAttribute (DKNet.EfCore.Abstractions.Attributes)

[AttributeUsage(AttributeTargets.Field)] — applies to an enum member, not to an entity property.

Member Type Default Effect
Type (ctor arg, optional) Type typeof(int) The sequence’s data type. Only byte, short, int, long; anything else throws NotSupportedException when the attribute is constructed.
Cyclic bool true Whether the sequence wraps back to its minimum after reaching its maximum.
StartAt long -1 Starting value. Applied only when greater than zero; otherwise the database default stands.
IncrementsBy int -1 Step size. Applied only when greater than zero.
Min long -1 Minimum value. Applied only when greater than zero.
Max long -1 Maximum value. Applied only when greater than zero.
FormatString string? null Format applied by NextSeqValueWithFormat. {1} is the sequence value; the literal token DateTime becomes {0} bound to DateTime.UtcNow.

SqlSequenceAttribute (DKNet.EfCore.Abstractions.Attributes)

[AttributeUsage(AttributeTargets.Enum)].

Member Type Default Effect
Schema (ctor arg, optional) string "seq" Database schema every sequence declared by that enum is created in. Read-only after construction.

Audit-log markers (DKNet.EfCore.Abstractions.Attributes)

Only SensitiveDataAttribute carries configuration; for the rest, presence is the whole configuration. All are sealed and Inherited = false.

Attribute Targets Effect
AuditLogAttribute Class, Property On a class: opts the entity in under AuditLogBehaviour.OnlyAttributedAuditedEntities. On a property: forces plaintext capture past the sensitive-name patterns, and allow-lists it under AuditPropertyPolicy.OnlyAttributedProperties.
IgnoreAuditLogAttribute Class, Property Excludes it from audit logging unconditionally, whatever the behaviour and policy.
SensitiveDataAttribute Property Always redacts the value in the audit log, even alongside [AuditLog] on the same property. Also gates the property in API responses for hosts that opted into role-aware serialization.
IgnoreEntityAttribute Class Declared as an opt-out from automatic entity mapping — see the caveat in Excluding a class from automatic mapping.

SensitiveDataAttribute’s own surface:

Member Type Default Effect
roles (trailing params string[] ctor arg) string[] empty Role names permitted to receive the property in an API response. [SensitiveData] with no argument stays valid.
Roles IReadOnlyList<string> empty, never null Read-only view of the declared role names. Empty means any authenticated caller, not everyone.

CRUD vertical-slice markers (DKNet.EfCore.Abstractions.Attributes)

Consumed by DKNet.SlimBus.Generators. All three are AllowMultiple = false.

Attribute Targets Member Type Default Effect
CrudCreateAttribute Constructor, Method Name string? null Overrides the generated Create request type’s name.
CrudUpdateAttribute Method Name string? null Overrides the generated Update request type’s name.
CrudActionAttribute Method route (ctor arg, optional) string? null → kebab-cased method name Route segment appended after {id}/.
    Verb CrudActionVerb Post Post, Put or Patch. There is no Delete.
    Name string? null Overrides the generated request type’s name.

Interfaces you implement

Contract Member with a default Default Effect
IEntity<out TKey> — — TKey Id { get; } only.
IAuditedProperties — — CreatedOn, CreatedBy, UpdatedOn, UpdatedBy; all four carry [IgnoreAuditLog], and the two By properties [MaxLength(500)] — note DKNet.EfCore.Extensions overrides that to 255 in DefaultEntityTypeConfiguration<T>.
IConcurrencyEntity<TType> RowVersion — Pre-annotated [Timestamp] and [Column(Order = 1000)] so the token sorts last in generated schemas.
ISoftDeletableEntity Delete(byUser, deletedOn) deletedOn defaults to null Returns IResultBase, so an implementation can refuse the delete without throwing.
IEventItem / EventItem AdditionalData ordinal case-insensitive dictionary [JsonIgnore]; meant for message headers. DKNet.EfCore.Events stamps sourceType into it.
IEventItem / EventItem EventType GetType().FullName String type tag carried on the serialized payload.
IEventPublisher / DefaultEventPublisher batch PublishAsync sequential foreach Override only the single-event method to get a working batch implementation.

🧱 Where it fits

The one mechanism in this package with real machinery behind it is [RaisesEvent] name composition — the same EventNameComposer source file is compiled into this assembly and linked into DKNet.EfCore.DtoGenerator, so the name the build emits and the name the save hook looks up can never disagree:

Data-flow diagram of a RaisesEvent declaration: the type-naming form points straight at a hand-written GenerateDto record, while both convention forms go through EventNameComposer, whose composed name the DtoGenerator emits as a payload record and the runtime EventHook re-composes at save time.

Otherwise this package is deliberately inert — every other EfCore package supplies the runtime behavior against the types declared here. Concretely (all verified by reading the consuming source, not assumed):

⚠️ Gotchas & limits