Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/the-model-the-scheduler-can-query && read --section="top" 0%
.NET

The Model the Scheduler Can Query

The third persistence strategy is the plainest one, and the only one a background poller can interrogate - plus what that poller costs when it has no watermark, no paging and no index.

By Kumar Chandrachooda 02 Dec 2025 6 min read
Plain rows with one column highlighted by a repeating sweep

This part closes the persistence arc, and it closes it on the strategy nobody writes talks about. Part 6 gave Sales three tables and no queries. Part 8 gave Postsale eight tables and underscore columns. TimeManagement does the thing every ORM tutorial does: a class is a table, its properties are columns, its children are child tables.

And it is the only one of the three that works, because of a single line in a background service.

The plainest mapping in the estate

// src/TimeManagement/.../Data/EF/Configs/DeadlineConfiguration.cs:10-24
public void Configure(EntityTypeBuilder<Deadline> builder)
{
    builder.HasKey(x => x.Id);
    builder.Property(x => x.Id)
        .HasConversion(x => x.Value, x => new DeadlineId(x))
        .IsRequired();

    builder.Property(x => x.CommunicationChannel).IsRequired();
    builder.OwnsOne(x => x.Message);
    builder.Property(x => x.DeadlineDateUtc).IsRequired();
    builder.Property(x => x.Fulfilled).IsRequired(false);

    builder.HasMany(x => x.Participants);
    builder.HasMany(x => x.Notifications);
}

Fifteen lines. One value-object converter for the strongly-typed id, one owned type for the message, two collections. Every member is addressed by a lambda rather than a string, because every member is a public property. The entity is the one from Part 2: public bool? Fulfilled { get; set; } and no methods at all.

Set that against the two Clean Architecture modules:

Sales TimeManagement Postsale
Row shape aggregate as one JSON document class is a table, children are child tables table-per-type over private fields
Model access serialise the whole graph public { get; set; }, EF binds directly backing fields, no accessors
Column names Id, Type, Object domain names private C# field names
Queryable by the key, and one promoted column every column every column, via EF.Property<>
Mapping code ~40 lines ~15 lines ~190 lines

The anaemic model is not an accident of laziness. It is what makes the next section possible.

The query that decides the whole comparison

// src/TimeManagement/.../Services/DeadlineService.cs:102-126
var now = _clock.UtcNow;

var activeDeadlines = _dbContext.Deadlines
    .Include(d => d.Notifications)
    .Include(d => d.Participants)
    .Where(d => d.Fulfilled == null).ToList();

foreach (var activeDeadline in activeDeadlines)
{
    if (activeDeadline.DeadlineDateUtc <= now)
    {
        activeDeadline.Fulfilled = false;
        await _eventDispatcher.PublishAsync(new DeadlineOverdueIntegrationEvent(activeDeadline.Id), cancellationToken);
    }
    else
    {
        await NotifyDeadline(activeDeadline, now, cancellationToken);
    }

    _dbContext.Deadlines.Update(activeDeadline);
    await _dbContext.SaveChangesAsync(cancellationToken);
}

Where(d => d.Fulfilled == null). That predicate runs every five seconds, forever, over the whole table.

Now try to write it against Sales. The equivalent state lives inside a serialised Offer or ReservationAcceptOfferDeadline.Fulfilled, PassengerNamesDeadline.Fulfilled, and so on — buried in a jsonb column, at a path that depends on which aggregate type owns it. You could reach it with a Postgres JSON operator if you were prepared to hard-code the C# property path into SQL. Sales does not; it maintains a whole side table (DeadlineRegistryEntries) instead, and still cannot answer “which deadlines are unfulfilled” — the registry maps ids to owners, not states.

Try it against Postsale. It is expressible — EF.Property<bool>(c, "_isActive") proves the mechanism works — but you would be writing a query keyed on a private field name.

The persistence style and the polling scheduler are one decision seen from two ends. TimeManagement's model is anaemic because a background poller has to interrogate it in SQL, and a domain model that hides its state cannot be interrogated in SQL. That is the honest justification, and it is a better one than “this module was simple so we did CRUD”.

The reciprocal is worth stating too: choose document persistence and you have chosen against background processing over that data. Every polling loop, every “find everything in state X” job, every reconciliation sweep becomes a full table scan plus N deserialisations, or a promoted column, or a side table. Sales has all three.

What the poll costs

Having earned the query, the scheduler then spends it carelessly. This is the honest half.

No watermark, no date filter, no paging. Every five seconds the pass materialises every unfulfilled deadline with two Includes. The filter that would make it cheap — something like Where(d => d.DeadlineDateUtc <= now || d.NextNotificationDue <= now) — is exactly the one absent. This is a table-as-queue with no cursor: the query has no idea which rows it already looked at four seconds ago.

No index. Fulfilled is unindexed, as is every column in the module. WorkloadManagement declares the only non-key indexes in the estate; Inquiries, Finance, Backoffice and TimeManagement declare none between them. The single hottest query in the system runs a sequential scan every five seconds.

Synchronous .ToList() inside an async method. Line 109 blocks the thread-pool callback thread on database I/O. MarkDeadlineFulfilled and UpdateDeadline do the same with SingleOrDefault.

Save per deadline, no transaction. SaveChangesAsync is inside the loop. A failure mid-list leaves the batch half-processed — and, by luck rather than design, that is nearly harmless: the next pass re-reads whatever is still Fulfilled == null. Accidental idempotence, purchased by the same filter that costs the scan.

One notification per deadline per pass. NotifyDeadline takes MinBy(n => n.DueDate) of the pending notifications and sends only that one. After downtime, the system catches up at one notification per deadline per five-second tick, sending reminders for windows that closed days ago. There is no staleness cutoff.

Two different delivery guarantees in twenty lines. NotifyDeadline sends the message and then marks the notification sent, relying on the caller's save two frames up — so a crash between them re-sends on the next pass. That is at-least-once for notifications. Meanwhile the overdue branch sets Fulfilled = false and publishes into an in-memory queue drained on its own timer; the row commits before the event is dispatched, and a crash in that window loses the event permanently, because the Fulfilled == null filter will never select that row again. That is at-most-once for the overdue event, with no re-drive path. Part 12 takes that apart properly.

The scheduler's own hazards

// src/TimeManagement/.../Schedulers/DeadlineScheduler.cs:49-66
private async void DoWork(object? state)
{
    try
    {
        using var scope = _serviceProvider.CreateScope();
        var deadlineService = scope.ServiceProvider.GetService<IDeadlineService>();
        await _semaphore.WaitAsync();
        await deadlineService.ProcessActiveDeadlines();
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, ex.Message);
    }
    finally
    {
        _semaphore.Release();
    }
}

Two lines are transposed, and it matters. _semaphore is new(1, 1) — maximum count one. If CreateScope() or GetService throws before WaitAsync on line 55, the catch logs it, and then finally calls Release() on a semaphore that was never taken. That throws SemaphoreFullException, from a finally block, inside an async void method — which means it is unobserved on a thread-pool thread and takes the process down.

The estate's two sibling implementations both put WaitAsync as the first statement inside the try. This file is a copy of that shape with the acquire moved down two lines.

Add the usual timer hazards: no re-entrancy guard, so a pass exceeding five seconds queues callbacks blocked on the semaphore; and StopAsync returns Task.CompletedTask without stopping the timer or draining, so shutdown discards whatever is in flight.

And the startup loop is worth a note in the other direction:

var dbContextInitialized = false;
while (dbContextInitialized is false)
{
    try
    {
        var dbContext = scope.ServiceProvider.GetService<TimeManagementDbContext>();
        await dbContext.Deadlines.FirstOrDefaultAsync(cancellationToken);
        dbContextInitialized = true;
        break;
    }
    catch (Exception ex)
    {
        await Task.Delay(2000);
    }
}

IHostedService.StartAsync is awaited sequentially by the host. If Postgres never comes up, this loop never exits and the API never starts serving — no maximum attempts, no backoff, cancellationToken not honoured by the delay, and nothing logged despite _logger being right there. It is also defending against a condition that cannot normally occur, because migrations already ran before hosted services started. The only such loop in the estate is guarding the wrong thing.

The scorecard, closed

Sales — jsonb TimeManagement — classic relational Postsale — encapsulated relational
Tables 3 3 8
Mapping code ~40 lines ~15 lines ~190 lines
Domain model rich none fully encapsulated
Query by non-key no yes, by domain name yes, by private field name
Background-pollable no yes with effort
Schema reveals an opaque document domain concepts private C# fields
Model change no migration migration migration

Three strategies, and the one with the least design in it is the one the estate depends on every five seconds. Choose your persistence by the questions you will need to ask, not by how DDD the write path looks. That is the whole arc in one line.

Next, a specification that never specifies anything — a named pattern implemented against its own intent, with a measurable cost and a one-line fix.