MartinDrozdik.DDD.Web 0.11.0

dotnet add package MartinDrozdik.DDD.Web --version 0.11.0
                    
NuGet\Install-Package MartinDrozdik.DDD.Web -Version 0.11.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="MartinDrozdik.DDD.Web" Version="0.11.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="MartinDrozdik.DDD.Web" Version="0.11.0" />
                    
Directory.Packages.props
<PackageReference Include="MartinDrozdik.DDD.Web" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add MartinDrozdik.DDD.Web --version 0.11.0
                    
#r "nuget: MartinDrozdik.DDD.Web, 0.11.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package MartinDrozdik.DDD.Web@0.11.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=MartinDrozdik.DDD.Web&version=0.11.0
                    
Install as a Cake Addin
#tool nuget:?package=MartinDrozdik.DDD.Web&version=0.11.0
                    
Install as a Cake Tool

DDD for ASP.NET Core - Web Plumbing That Doesn't Suck

NuGet NuGet Downloads Build & Test .NET License

Opinionated web infrastructure for .NET based on MartinDrozdik.DDD and MartinDrozdik.DDD.Options. Includes error handling, logging, telemetry, health checks, and other setup you'll need anyway. Check the demo.

Installation

dotnet add package MartinDrozdik.DDD.Web

Also check this repos' DDD Claude Code plugin for better AI code generation.

Philosophy

Same as the core DDD library.

This package provides basic scaffolding for ASP.NET apps while staying out of your way when you need to do your thing. Everything is optional, but it's tested together.

iT jUsT wOrKs

Quick Start

Bare minimum setup - one liner to get logging, error handling, OpenAPI, health checks, telemetry, and HTTP resilience:

var builder = WebApplication.CreateBuilder(args);
builder.AddAppServices(); // the magic one liner

// ...

var app = builder.Build();
app.UseAppMiddlewares(); // the other magic one liner

// ...

await app.RunAsync();

Done. You've got a production-ready baseline. Now go build your actual features.

You want to use Aspire? We got you fam! The OTEL works with Aspire out of the box.

All-in-One Setup

The AddAppServices() extension registers:

  • Logging - Structured logging that actually helps you debug but doesn't leak sensitive info in production
  • Error Handling - Converts your DDD exceptions to proper HTTP RFC7807 error responses
  • OpenAPI - Auto-generated API docs (because manually writing swagger is hell)
  • Health Checks - basic /health, /health/live and /health/ready endpoints
  • OpenTelemetry - Metrics, traces, and logs (exports to OTLP when configured via OTEL environment variables)
  • HTTP Resilience - Default policies for HTTP clients
  • Static file path provider - Because you probably need to serve some static files at some point

Don't want all of it? Use the individual extensions instead. I won't judge.

Or just turn them off via settings:

var options = MartinDrozdik.DDD.Web.WebApplicationOptions.Default with
{
    UseStaticFilePathProvider = false,
};
builder.AddAppServices(options);

Modules

What goodies do you want to use? Just call the appropriate extension method:

Options

In its own package - see MartinDrozdik.DDD.Options. This package adds IWebHostBuilder.SetOption<TOptions>() on top of it.

EEE (Ezy Error Ehndling)

Automatic conversion of DDD exceptions to HTTP responses:

  • BusinessRuleValidationException → 400 Bad Request
  • ValidationException (FluentValidation) → 400 Bad Request
  • BusinessNotFoundException → 404 Not Found
  • BusinessRuleException → 500 Internal Server Error, via the catch-all handler rather than a dedicated one
  • Anything else → 500 Internal Server Error

In development, you get detailed info — stack traces and exception details.

Note that the catch-all handler puts the raw exception.Message in the response in every environment, so keep anything sensitive out of the messages of exceptions you expect to escape a handler.

The middleware handles it:

builder.Services.AddAppErrorHandling();
//...
app.UseExceptionHandler();

Your domain layer throws exceptions. The middleware translates them. You don't touch HTTP in your business logic.

Database Setup

There is 99 % chance you are using EF core with relational database.

We got you, just use the extension method:

// appsettings.json
{
  "App": {
    "Database": {
      "ConnectionString": "Data Source=app.db"
    }
  }
}
// With DatabaseOptions from config:
builder.AddAppDbContext<YourDbContext>((options, dbBuilder) =>
{
    dbBuilder.UseSqlite(options.ConnectionString);
    // Use SQL Server, PostgreSQL, MySQL, whatever you like.
});

// Or manual configuration:
builder.AddAppDbContext<YourDbContext>(dbBuilder =>
{
    dbBuilder.UseSqlServer(connectionString);
});
  • In development: sensitive data logging and detailed errors.
  • In production: none of that.

Ensure your database exists:

await app.EnsureCreatedDatabaseAsync<YourDbContext>();

Or for you migration folks:

await app.EnsureMigratedDatabaseAsync<YourDbContext>();

And EnsureDeletedDatabaseAsync<YourDbContext>() when you want it gone.

Health Checks

The most basic liveness and readiness probes. Because Kubernetes will ask:

builder.AddAppHealthChecks();

// Or add custom checks:
builder.AddAppHealthChecks(checks =>
{
    // Add more checks as needed
});

app.MapAppHealthChecks(); // Registers /health/live and /health/ready

Timeouts are configured. Todd says "it just works".

Mediator

Automatic request/response logging for your CQRS handlers:

builder.Services.AddMediator(config =>
{
    var integration = new LoggingPipelineIntegrator()
        .Merge<ValidationPipelineIntegrator>();
    
    config.WithCommand<CreateInvoiceCommand, InvoiceId, CreateInvoiceCommandHandler>(integration);
});

Every command and query gets logged with info.

Telemetry (OpenTelemetry)

Traces, metrics, and logs for ASP.NET Core and HTTP clients:

builder.AddAppOpenTelemetry();

Configure export via environment variables:

OTEL_EXPORTER_OTLP_ENDPOINT=http://your-collector:4317
OTEL_SERVICE_NAME=your-service
OTEL_SERVICE_VERSION=1.0.0

Check the docs with full list of OTEL environment variables.

Health check requests are filtered out of traces because who cares about those.

DDD Context / EF mapping

Expanded DbContext child called DddDbContext with some extra features tailored for DDD apps, such as:

  • OnAggregatesSave – changed entries whose type is an IAggregateRoot<T>
  • OnDomainEntitiesSave – changed entries whose type is an IDomainEntity<T>
  • OnObjectsSave – every changed entry, whatever it is

Coming with useful mapping extensions. Example:

public class InvoiceDbContext(/*...*/) : DddDbContext(options)
{
    private const string CreatedAtPropertyName = "CreatedAt";

    // ...

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(InvoiceDbContext).Assembly);

        // Register audit shadow properties
        foreach (var entityType in modelBuilder.Model.GetAggregateRoots())
        {
            modelBuilder.Entity(entityType.ClrType)
                        .Property<DateTimeOffset>(CreatedAtPropertyName);
            // ...
        }
    }

    protected override void OnAggregatesSave(IEnumerable<EntityEntry> entityEntries)
    {
        base.OnAggregatesSave(entityEntries);
        var now = timeProvider.GetUtcNow();
        foreach (var entry in entityEntries.Where(e => e.State == EntityState.Added))
        {
            entry.Property(CreatedAtPropertyName).CurrentValue = now;
        }
    }
}

Reverse Proxy Support

One liner for handling proxied requests (nginx, YARP etc.):

app.IsBehindProxy();

Handles X-Forwarded-* headers.

Redirects

You launched the new site and its URLs changed. Other sites still link to /contact.html. Send those visitors somewhere useful:

builder
    .AddPermanentRedirect("/contact.html", "/") // HTTP 301
    .AddPermanentRedirect(new Redirect("/about-us.html", "/about")) // HTTP 301
    .AddTemporaryRedirect("/blog", "https://blog.example.com/"); // HTTP 302

var app = builder.Build();
app.UseAppMiddlewares();
app.UseRedirects();
app.UseStaticFiles();

HTTP Client Resilience

builder.Services.AddHttpClientResilience();

Because the network is unreliable and you know it.

Static File Path Provider

How much time did you spend solving an issue solved by clearing browser cache? Yeah, me too. This path modification with query string versioning is the most basic solution to this problem.

IStaticFilePathProvider provider; // inject it where you need it
provider.PathTo("file.js");

Returns a version depending on the environment:

  • Development: returns "file.js?version={unix-timestamp}" to bust cache on every request.
  • Production: returns "file.js?version=1.2.3" depending on your appsettings. Bust cache when you deploy a new version, but not on every request.
// appsettings.json
{
  "App": {
    "StaticFileVersioning": {
        "Version": "1.2.3"
    }
  }
}

Recurring Background Tasks

Every app ends up needing that one job on a loop. Cleanup, reindexing, sending the queued mail. And every hand-rolled version gets the same four things wrong: it dies on the first unhandled exception, it grabs a DbContext from the root scope, it uses Task.Delay so you can't test it, and there's no way to say "actually, run it now".

Write the job. Skip the plumbing.

public class CleanupTask(InvoiceDbContext context, ILogger<CleanupTask> logger) : IRecurringTask
{
    // Resolved from a fresh DI scope every iteration, so scoped services just work
    public async Task RunAsync(CancellationToken cancellationToken)
    {
        var removed = await context.Drafts.Where(d => d.IsAbandoned).ExecuteDeleteAsync(cancellationToken);
        logger.LogInformation("Swept up {Removed} abandoned drafts.", removed);
    }
}

// Register it, schedule and all:
builder.AddRecurringTask<CleanupTask>(options =>
{
    options.InitialDelay = TimeSpan.FromSeconds(30);
    options.Period = TimeSpan.FromMinutes(10);
    options.Timeout = TimeSpan.FromMinutes(5);
});
  • InitialDelay – how long to wait after startup, so background work doesn't elbow its way into the startup burst.
  • Period – the gap between iterations, measured from when the previous one finished, not when it started. Iterations never overlap and a slow run can never build a backlog.
  • Timeout – optional. Cancels the iteration's token and logs a warning the moment it overruns. The loop continues on once the iteration returns, so iterations still never overlap.
  • Enabled – decided at startup. false and the loop never even begins.
  • RunBetween – optional daily window of local time, e.g. new(new TimeOnly(22, 0), new TimeOnly(4, 0)) for nights. Checked just before each iteration. Outside it, the loop waits right until the window opens, so the job starts on the dot. A window may wrap past midnight.
  • MaxRunsPerDay – optional cap on started iterations per day (failed ones count too). Once reached, the loop waits until the next day, which starts when the window opens, or at midnight without one. So a night from 22:00 to 04:00 counts as one day.

Once a night, starting on time every night:

builder.AddRecurringTask<NightlyReportTask>(options =>
{
    options.RunBetween = new(new TimeOnly(2, 0), new TimeOnly(4, 0));
    options.MaxRunsPerDay = 1;
    // After its one run, failed or not, the loop waits for 02:00 tomorrow, so the Period barely matters here
    options.Period = TimeSpan.FromMinutes(30);
});

⚠️ MaxRunsPerDay is an in-memory counter. It starts over with every restart. Need a hard guarantee? See the schedulers below.

All options are validated.

A failing iteration is logged with its exception and the loop keeps going. One bad run does not silently kill your job.

Each task logs under a category of its own, MartinDrozdik.DDD.Web.RecurringTasks.RecurringTaskHost.{task namespace}.{task name}. To quieten one chatty task without touching the others:

"Logging": {
  "LogLevel": {
    "MartinDrozdik.DDD.Web.RecurringTasks.RecurringTaskHost.MartinDrozdik.DDD.Web.Outbox.OutboxDispatchRecurringTask<InvoiceDbContext>": "Warning"
  }
}

The schedule lives in code and memory. Light and simple on purpose. For heavy-duty complex stuff with complex crons and distributed schedules, I would recommend:

Immediate Trigger

Need it to run right now? Inject the trigger and ask:

public class InvoicesController(IRecurringTaskTrigger<CleanupTask> trigger) : ControllerBase
{
    [HttpPost("cleanup")]
    public IActionResult Cleanup()
    {
        trigger.Trigger(); // returns immediately, the loop wakes up and does the work
        return Accepted();
    }
}

Triggers are coalesced — hammer it a thousand times during one iteration and you get one extra run, not a thousand.

It never blocks and never throws, so it's safe to call from anywhere.

Transactional Outbox

An outbox message is enqueued atomically with the rest of the DDD updates. It is a reliable, transactional queue that writes or reverts together with the domain updates.

The outbox makes the side effect part of the same transaction: the message is written to a table in your database by your SaveChangesAsync. Both, or neither. A background loop delivers it afterwards and keeps retrying until it sticks or finally dies.

Every state a message goes through (retries, expired leases, cancelled runs, dead letters) is mapped in the outbox message lifecycle.

A message is a plain serializable record with a stable storage key:

public sealed record InvoiceDraftedMessage(Guid InvoiceId, string InvoiceNumber, string Recipient) : IOutboxMessage
{
    public static OutboxMessageType MessageType => "invoice.drafted.v1";
}

Its handler does the actual work. No try/catch, no bookkeeping → throw and it gets retried as much as you want:

public class InvoiceDraftedMessageHandler(IEmailSender sender) : IOutboxMessageHandler<InvoiceDraftedMessage>
{
    // Resolved from a fresh DI scope per message, so scoped services just work
    public Task HandleAsync(InvoiceDraftedMessage message, CancellationToken cancellationToken)
        => sender.SendAsync(message.Recipient, $"Invoice {message.InvoiceNumber} is ready.", cancellationToken);
}

Map the table into the context that owns your aggregates — same database, same transaction, that's the whole point:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromAssembly(typeof(InvoiceDbContext).Assembly);

    modelBuilder.AddOutbox("OutboxMessage");
    // modelBuilder.AddOutbox(new TableName("Messages", "outbox")) to put it in a schema
}

Every context has a separate outbox table.

builder.AddOutbox<InvoiceDbContext>(config => config
    .WithMessage<InvoiceDraftedMessage, InvoiceDraftedMessageHandler>()
    .WithMessage<InvoicePaidMessage, InvoicePaidMessageHandler>());

// The message consumer invoking the dispatch loop
builder.AddOutboxDispatchRecurringTask<InvoiceDbContext>(schedule =>
{
    schedule.InitialDelay = TimeSpan.FromSeconds(10);
    schedule.Period = TimeSpan.FromSeconds(30);
});

// Optional: delete delivered messages after a week, in chunks of BatchSize (1000). Without it they are kept forever.
builder.AddOutboxRetention<InvoiceDbContext>(options => options.Retention = TimeSpan.FromDays(7));
builder.AddOutboxRetentionRecurringTask<InvoiceDbContext>(schedule =>
{
    schedule.Period = TimeSpan.FromHours(1);
    schedule.RunBetween = new(new TimeOnly(1, 0), new TimeOnly(5, 0)); // optional, keeps the deletes out of busy hours
});

Enqueue wherever the business demands it:

public class CreateInvoiceDraftCommandHandler(InvoiceDbContext context, IOutbox<InvoiceDbContext> outbox)
    : ICommandHandler<CreateInvoiceDraftCommand, InvoiceId>
{
    public async Task<InvoiceId> HandleAsync(CreateInvoiceDraftCommand command, CancellationToken cancellationToken)
    {
        var invoice = Invoice.CreateDraft(issuer, recipient, invoiceNumber);
        await context.Invoices.AddAsync(invoice, cancellationToken);

        outbox.AddOnSave(new InvoiceDraftedMessage(invoice.Id.Key, invoiceNumber.ToString(), recipient.FullName));

        await context.SaveChangesAsync(cancellationToken); // both, or neither
        return invoice.Id;
    }
}

AddOnSave() only tracks the row. It never saves. You save it all along with your aggregate.

IOutbox<TContext> names the context it tracks in, so an outbox can't be paired with another context's save by mistake.

IOutbox<TContext>, IOutboxMessage, IOutboxMessageHandler<T> and OutboxMessageType contracts live in the core MartinDrozdik.DDD package, so a business layer can be implemented without further dependencies.

Scheduled messages - undo what a rollback leaves behind

AddOnSave() covers "do this once my transaction commits". AddNowAsync() with a later AvailableAt covers the "undo this unless my transaction commits":

var settings = new OutboxMessageSettings {
    AvailableAt = now + TimeSpan.FromHours(1) // schedule for later with grace period 1h
}; 
var removal = await outbox.AddNowAsync(new DeleteFileMessage(path), settings, ct); // committed at once
await WriteFileAsync(path, ct); // a side effect
await outbox.RemoveOnSaveAsync(removal, ct); // tracked
await context.SaveChangesAsync(ct); // removes it with your changes

The *NowAsync messages are committed at once through a fresh instance of your context from a scope. Your transactions, explicit or ambient, are left exactly as they were. *OnSaveAsync() work in your commit.

Committing on its own connection may work differently on some databases. F.e. SQLite starts transactions as immediate , and an AddNowAsync() inside it then waits for its own transaction until it times out. Best to call *NowAsync() before beginning your transaction, or begin a deferred one (connection.BeginTransaction(deferred: true) with Database.UseTransaction) and call it before its first save.

Set you preferred options for the outbox, like batch size, retry delays, and so on:

builder.AddOutbox<InvoiceDbContext>(options => { /* ... */ }, config => { /* ... */ });

Use health check to monitor large backlogs or dead-lettered messages:

builder.AddAppHealthChecks(checks => checks.AddOutboxHealthCheck<InvoiceDbContext>());
// reported as "outbox-InvoiceDbContext", tagged "ready" - or pick your own:
// checks.AddOutboxHealthCheck<InvoiceDbContext>("invoice-outbox", ["ready"], degradedBacklogThreshold: 500)

Degraded while any message is dead-lettered or more messages are overdue than the threshold, unhealthy only when the table cannot be read. Never unhealthy over a dead letter: the table is shared and dead letters are kept until you delete them, so one poison message would take every instance out of /health/ready for good. A degraded check still answers 200 there, so alert on the degraded status or the deadLettered count instead. How a message becomes a dead letter, and what to do with one: Outbox Diagram.

Versioning – end the key with .v1

The key is what maps a stored row back to a type. Rename it and every row carrying the old key becomes undeliverable. So don't rename it: bump it.

// v1 — shipped, rows in the table
public sealed record InvoiceDraftedMessage(Guid InvoiceId, string InvoiceNumber) : IOutboxMessage
{
    public static OutboxMessageType MessageType => "invoice.drafted.v1";
}

// Breaking change (Recipient is now required) -> a new type under a new key
public sealed record InvoiceDraftedV2Message(Guid InvoiceId, string InvoiceNumber, string Recipient) : IOutboxMessage
{
    public static OutboxMessageType MessageType => "invoice.drafted.v2";
}

// Both registered until the v1 rows have drained, then drop v1
config.WithMessage<InvoiceDraftedV1Message, InvoiceDraftedV1MessageHandler>()
      .WithMessage<InvoiceDraftedV2Message, InvoiceDraftedV2MessageHandler>();

Adding an optional member? Keep the key — an old payload still deserializes. Anything a stored v1 payload can't satisfy? Bump it.

Start at .v1 on day one, even when you're sure it'll never change. It will.

Deliver it now, not in 30 seconds (optional)

Attach OutboxTaskTriggerInterceptor<TContext> to your context and wake up the dispatch loop immediately to avoid waiting for the next poll.

builder.AddAppDbContext<InvoiceDbContext>((options, provider, dbBuilder) =>
{
    dbBuilder.UseSqlite(options.ConnectionString);
    dbBuilder.AddInterceptors(provider.GetRequiredService<OutboxTaskTriggerInterceptor<InvoiceDbContext>>());
});

You can also inject IRecurringTaskTrigger<OutboxDispatchRecurringTask<InvoiceDbContext>> and call Trigger() from anywhere. It's coalesced.

Purely about responsiveness. Leave it out and the message simply waits for the next poll.

Bring your own scheduler

AddOutboxDispatchRecurringTask is just a thin shell. Skip it and drive IOutboxProcessor<TContext> from Quartz.NET, Hangfire, or a cron hitting an endpoint.

public sealed class InvoiceOutboxJob(IOutboxProcessor<InvoiceDbContext> processor) : IJob
{
    public async Task Execute(IJobExecutionContext context)
    {
        OutboxBatchResult result;
        do
        {
            result = await processor.ProcessPendingAsync(context.CancellationToken);
        }
        while (result.MayHaveMore && !context.CancellationToken.IsCancellationRequested);
    }
}

With AddOutboxRetention, schedule IOutboxRetention<TContext>.ApplyAsync(ct) as a second job, hourly or nightly, and skip AddOutboxRetentionRecurringTask.

Several instances

Multiple instances may run the outbox processing, safely!. Each run reserves its batch up front (BatchSize messages for ReservationDuration), so instances polling together deliver separate batches. If one message of a batch takes longer than ReservationDuration, the rest of the batch goes to other instances and the slow run skips it.

May be useful for high-throughput scenarios, or when you have like horizontal scaling and stuff...

Several contexts – an outbox each

The unit of isolation is the DbContext. Call AddOutbox<TContext> once per context.

// Shared infrastructure project
public static OutboxConfig WithEmails(this OutboxConfig config)
    => config.WithMessage<SendEmailMessage, SendEmailHandler>();

builder.AddOutbox<InvoiceDbContext>(config => config.WithMessage<InvoiceDraftedMessage, InvoiceDraftedMessageHandler>().WithEmails());
builder.AddOutbox<ShippingDbContext>(config => config.WithEmails());

The handler is registered once.

Worker app – plug the processing in and out

Producing and consuming messages can be in separate apps.

// Both apps, e.g. from a shared extension method of the bounded context
builder.AddOutbox<InvoiceDbContext>(config => config.WithMessage<InvoiceDraftedMessage, InvoiceDraftedMessageHandler>());

// Worker only
builder.AddOutboxDispatchRecurringTask<InvoiceDbContext>(schedule => schedule.Period = TimeSpan.FromSeconds(30));

Blob Storage

Solved saving files for you. And they commit together! An invoice scan, a profile picture, a generated export - content too big for a column, referred by an aggregate.

Solid, testable abstraction around local file storage that most applications actually need.

Three components:

Layer Type Knows about
Store IBlobStore Files, in-memory testing, the byte stuff
Catalogue the Blob table the row, the metadata, the transaction
Facade IBlobStorage coordinates both, applies policies, main interface for you

Blob storage requires the outbox of the same context. Every removal of content is an outbox message: after a delete commits, after a blob expires, and after an upload that never committed its row.

Map the catalogue in your OnModelCreating, next to the outbox:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromAssembly(typeof(InvoiceDbContext).Assembly);
    modelBuilder.AddOutbox("OutboxMessage");
    modelBuilder.AddBlobs(); // table "Blob", or AddBlobs(new TableName("Blob", "files"))
}

Register its messages into the outbox of the context first, then the engine with its containers, and pick a store:

builder.AddOutbox<InvoiceDbContext>(config => config
    .WithMessage<InvoiceDraftedMessage, InvoiceDraftedMessageHandler>()
    .WithBlobs());   // required - every removal of content rides on the outbox

builder.AddBlobs<InvoiceDbContext>(blobs => blobs
    .WithContainer("invoice-scans", containerOptions =>
    {
        containerOptions.MaxSize = 20 * 1024 * 1024;
        containerOptions.AllowedExtensions = new HashSet<string>(StringComparer.Ordinal) { "pdf", "png", "jpg" };
    })
    .WithContainer("avatars", containerOptions => containerOptions.MaxSize = 512 * 1024)
    .WithSweep(sweepOptions => sweepOptions.BatchSize = 500));   // optional

// Where content is kept is a separate decision - each container gets a folder of its own
builder.AddFileBlobStore(files => files
    .WithContainer("invoice-scans", Path.Combine(builder.Environment.ContentRootPath, "blobs", "invoice-scans"))
    .WithContainer("avatars", "/mnt/shared/avatars"));

builder.AddBlobSweepRecurringTask(taskOptions =>
{
    taskOptions.InitialDelay = TimeSpan.FromMinutes(1);
    taskOptions.Period = TimeSpan.FromHours(1);
});

Containers are separate, isolated entities. Each one is registered on its own with WithContainer, with its own BlobContainerOptions (MaxSize, AllowedContentTypes, AllowedExtensions, ComputeChecksum, OrphanGracePeriod) and its own folder.

Storing a file is a step in a transaction, not a transaction of its own:

var result = await blobStorage.AddOnSaveAsync(
    new BlobUploadRequest
    {
        Container = "invoice-scans",
        Content = file.OpenReadStream(),
        OriginalFileName = file.FileName,
        ContentType = file.ContentType,
    },
    cancellationToken);

invoice.AttachScan(result.Value.Id);
await context.SaveChangesAsync(cancellationToken); // the row and the aggregate, both or neither

AddOnSaveAsync() only tracks the row. It never saves. If the save rolls back, the file is later cleaned up (after the grace period ends). Keep the grace period comfortably longer than the slowest transaction that could be holding a blob.

Deleting works the same way:

context.Invoices.Remove(invoice);
await blobStorage.DeleteOnSaveAsync(scan, cancellationToken);
await context.SaveChangesAsync(cancellationToken); // both, or neither

The files are stored in the container folder, named after their BlobId:

{containerFolder}/{blobId}

blobs/invoice-scans/0198b7c4-…
Immutability

A blob cannot be rewritten. There is no LastModifiedAt to keep honest and its checksum is a strong HTTP ETag for as long as it exists:

Response.Headers.ETag = scan.Blob.Checksum?.ToETag();
return File(scan.Content, scan.Blob.ContentType.Value, scan.Blob.OriginalFileName);

Replacing a file means storing a new blob and enqueueing the deletion of the old one.

Metadata is the one mutable part - a small JSON column for facts the library cannot know, such as width/height. Anything you query on deserves a real column on an entity of your own.

The sweep

IBlobSweeper removes blobs whose ExpiresAt has passed, across every container.

Skip AddBlobSweepRecurringTask to drive IBlobSweeper from Quartz.NET or anything else. Skip it entirely and expired blobs simply stay.

Demo App

Check out the demo project for examples.

It's structured for demo purposes, I would recommend structuring with vertical slices in a real app, but it shows all the features in one place.

It's a simple ASP.NET Core app with a few endpoints, using the mediator for commands and queries, and demonstrating the error handling and telemetry in action. Check it out for examples of how to use the library in a real app...

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on MartinDrozdik.DDD.Web:

Package Downloads
MartinDrozdik.DDD.Testing

A library to support testing of websites using the MartinDrozdik.DDD packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.11.0 121 10/31/2026
0.10.0 134 9/15/2026
0.9.1 141 8/31/2026
0.9.0 149 8/8/2026
0.8.1 150 8/7/2026
0.8.0 197 8/1/2026
0.7.1.2 135 7/28/2026
0.7.1.1 165 7/28/2026
0.7.1 162 7/26/2026
0.7.0 353 5/16/2026
0.6.0 182 5/2/2026
0.5.3 210 4/19/2026
0.5.2 149 4/18/2026
0.5.1 162 4/10/2026
0.5.0 180 4/6/2026
0.4.4 164 4/6/2026
0.4.2 149 4/6/2026
0.4.1 170 4/6/2026
0.1.4 194 3/21/2026
0.1.3 212 2/14/2026
Loading failed