One Attribute, a Scanner, and a Registry
How ArchiveKit wires itself up - the [Archivable] attribute's three knobs, an assembly scanner that fails fast at startup instead of quietly at delete time, the static registry behind every lookup, and the AddArchiveKit configuration surface. Plus an honest note about the source generator that doesn't exist yet. Part 5 of the Archive-on-Delete with ArchiveKit series.
Every convention-based library has a bootstrapping story, and the quality of that story is measured in one place: when it tells you you've wired it wrong. At compile time is best. At startup is fine. At the moment a user deletes an order in production and the archive silently doesn't happen — that is the failure mode this post is engineered against.
ArchiveKit's bootstrapping is three small pieces: an attribute that declares policy, a scanner that discovers types and validates the wiring, and a registry that answers lookups for the rest of the process's life.
The attribute is the policy surface
Everything an entity gets to decide lives on [Archivable]:
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class ArchivableAttribute : Attribute
{
public bool Snapshot { get; set; } = false;
public bool CascadeArchive { get; set; } = false;
public int TtlDays { get; set; } = -1;
}
Three knobs, all defaulting to the cheapest behavior: no async snapshot work, no cascade claims, keep forever. The sample's three entities show the spread — Order turns everything on (Snapshot = true, CascadeArchive = true, TtlDays = 365), OrderLine is bare [Archivable], and Product takes scalars-only with a two-year retention (Snapshot = false, TtlDays = 730). Inherited = false is deliberate: archivability is a per-type decision, and a derived entity silently inheriting its base's TTL policy is exactly the kind of spooky action this library shouldn't have.
The attribute alone does nothing, though. An entity becomes archivable when three things line up: the attribute, an ArchiveEntry<T> subclass, and a SnapshotJob<T> subclass. Which raises the question of who checks the lineup.
The scanner fails loudly, on purpose
At startup, ArchiveKitScanner.Scan(assembly) walks every type, extracts the entity type argument from anything deriving (at any depth — it walks the base-type chain) from ArchiveEntry<> or SnapshotJob<>, and then plays matchmaker. The interesting part is everything it refuses to tolerate:
if (archiveTypes.ContainsKey(entityForArchive))
throw new InvalidOperationException(
$"ArchiveKit: Multiple ArchiveEntry<{entityForArchive.Name}> subclasses found " +
$"({archiveTypes[entityForArchive].Name} and {type.Name}). Only one per entity is allowed.");
Two archive classes for one entity: startup crash. An archive class with no matching snapshot class: startup crash (“Found OrderArchive (ArchiveEntry<Order>) but no matching SnapshotJob<Order> subclass”). An orphaned snapshot class: startup crash. Archive and snapshot classes present but [Archivable] missing from the entity: startup crash. Every message names the exact types involved, because a startup exception you can fix from the message alone is worth ten pages of documentation.
One more piece of scanner pragmatism: the type walk is wrapped for the messy real world. assembly.GetTypes() throws ReflectionTypeLoadException when any type in the assembly fails to load — a missing optional dependency is enough — and the scanner catches it and proceeds with the types that did load rather than taking the whole app down for a type it never needed. Scanning is also additive across calls, so options.ScanAssembly(...) can be called once per module in a modular monolith and each scan validates its own assembly's pairings.
You might push back on one of these: why require a snapshot class when Snapshot = false means it'll never hold a row? Because the registry stores an (entity → archive → snapshot) triple unconditionally, and the model builder maps both tables either way; flipping Snapshot = true later is then a one-character change plus a migration, not a new class and a re-scan of what else forgot it. The cost is a one-line file — public class ProductSnapshot : SnapshotJob<Product> { } — and an empty table. I'll take an empty table over a nullable code path in every consumer of the registry.
A registry with one writer and many readers
What the scanner produces lands here:
public static class ArchiveRegistry
{
private sealed record Registration(
Type ArchiveType,
Type SnapshotType,
ArchivableAttribute Attribute);
private static readonly ConcurrentDictionary<Type, Registration> _map = new();
public static bool IsArchivable(Type entityType) => _map.ContainsKey(entityType);
public static Type GetArchiveType(Type entityType) => _map[entityType].ArchiveType;
// ...
}
Write-once at startup, read from every save for the life of the process — the interceptor's hot-path check (part 2), the model builder's table generation (part 4), and both background services all resolve through it. Note the attribute instance itself is cached in the registration; GetCustomAttribute runs once per type, not once per delete.
Full disclosure: there is also an IArchiveRegistry interface in the abstractions package, and nothing implements or injects it. The static class won — statics are unbeatable for a read-mostly map that model-building code (which runs outside any DI scope) also needs — but the interface stays as the escape hatch for the day someone needs two isolated registries in one process (multi-tenant hosts with different archive policies is the scenario I can't quite rule out). An interface that exists but isn't wired is a design IOU, and I'd rather show you the IOU than pretend the static was a grand plan.
The front door: AddArchiveKit
The consumer-facing surface gathers it all:
builder.Services.AddArchiveKit(options =>
{
options.ScanAssembly(typeof(Order).Assembly);
options.InlineArchiveLimit = 14;
options.Snapshot.PollIntervalSeconds = 10;
options.Snapshot.BatchSize = 20;
options.Snapshot.MaxRetries = 3;
options.Ttl.RunIntervalHours = 24;
options.EnableSnapshotService = true;
options.EnableTtlCleanup = true;
});
builder.Services.AddDbContext<AppDbContext>((sp, opt) =>
{
opt.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"));
opt.AddInterceptors(sp.GetRequiredService<ArchiveInterceptor>());
});
builder.Services.AddScoped<IArchiveDbContext>(
sp => sp.GetRequiredService<AppDbContext>());
A few decisions worth their ink. Explicit registration exists for people who distrust assembly scanning: options.Register<Order, OrderArchive, OrderSnapshot>() does the same wiring with compile-time-checked generic constraints (it still demands the [Archivable] attribute — policy stays on the entity). Explicit registrations run before scans, and both paths refuse to double-register. The interceptor is scoped, built by factory so the options' InlineArchiveLimit flows onto it, and injected into the DbContext via the service-provider overload of AddDbContext — the one pattern that lets an interceptor take dependencies (here, a logger) instead of being a new'd singleton. The two hosted services are opt-out flags rather than separate packages, because the serverless story (part 8) is “same class, call the public method yourself,” and a boolean is the cheapest possible toggle between the two hosting modes. And the options objects are registered as plain singletons rather than through IOptions<T> — a deliberate austerity; nothing here needs reload-on-change, and IOptionsMonitor ceremony for three integers felt like cosplay.
One more piece of wiring lives on the consumer's side, and it's the lock that makes the interceptor's trigger reliable — the global soft-delete filter, applied in a loop so no entity can forget it:
foreach (var entityType in builder.Model.GetEntityTypes())
{
if (!typeof(ISoftDeletable).IsAssignableFrom(entityType.ClrType)) continue;
var method = typeof(AppDbContext)
.GetMethod(nameof(ApplySoftDeleteFilter),
BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(entityType.ClrType);
method.Invoke(null, [builder]);
}
About that source generator
If you read the XML docs in the abstractions package, they tell a slightly different story than this post: "Adding this attribute causes the ArchiveKit source generator to emit Archive, Snapshot, ArchiveConfiguration." There is no source generator. Today you write the subclasses by hand (they're one to five lines each) and the runtime scanner finds them. The doc comments describe the destination — generated partial classes, a generated registry populated by module initializer, zero reflection at runtime — and I've left them in as a spec I'm holding myself to. Runtime scanning was the two-evening version that let me validate the semantics first; codegen is the polish that changes nothing about how the library behaves, only what it costs. Ship the semantics, then optimize the plumbing.
The one attribute knob this post skipped — CascadeArchive — deserves its own scrutiny, because what it promises and what the interceptor currently enforces are not the same thing.
Next up: correlation ids, and how a parent and its children get archived as one family.