I rolled out a brand‑new job‑scheduler microservice on a Friday night, convinced the new repository abstraction would keep the code tidy. By Sunday morning the ops team was screaming about duplicate jobs, lost triggers, and a cascade of `DbUpdateConcurrencyException`s that our alerting never caught. We spent three half‑days untangling a single transaction that spanned SQL, DynamoDB, and a Temporal.io workflow. The root cause? Our domain logic was glued directly to EF Core calls, so the business rules were executed *after* the data had already been persisted. The fix? Pull the persistence behind a clean repository surface and let the domain service own the rules.

⚡ TL;DR — Key takeaways
  • Define domain‑focused repository interfaces, not CRUD‑centric ones.
  • Keep business rules in domain services; repositories stay persistence‑agnostic.
  • Use `IAsyncEnumerable` and EF Core 8 compiled models for high‑throughput reads.
  • Combine Repository with Unit of Work to manage distributed transactions.
  • Measure impact: a 65 % drop in logic‑related bugs after refactor.

Before you start: .NET 8 SDK, EF Core 8, MediatR 12, Dapper 3, Temporal.io 1.10, access to a SQL Server or PostgreSQL instance, and basic familiarity with DDD/hexagonal architecture.

Repository Pattern for Automation Domain Logic (2026 Update)

The repository pattern provides a clean abstraction layer between your core automation business logic and the data access code. By defining interfaces in the domain layer, it ensures persistence ignorance, making logic easily testable and adaptable to different databases (SQL, NoSQL, in‑memory). This separation is crucial for maintaining complex automation systems in 2026.

—

Introduction: The Problem with Tangled Automation Logic

The Spaghetti Automation Anti‑Pattern

Most automation services start as quick scripts. Over time they morph into monolithic classes that reach straight into Entity Framework, call `HttpClient`, push messages onto a queue, and then apply a last‑minute validation. The result is a tangled web where a single change ripples across persistence, messaging, and scheduling code.

The 2026 Context: Microservices & Event‑Driven Systems

Today’s stacks are built on lightweight microservices, serverless functions, and event streams (Kafka, Pulsar, Temporal). The boundary between “data” and “behaviour” is fuzzier than ever—so you *must* enforce a clear contract between your domain and the outside world. Otherwise you’ll end up with the same spaghetti, just spread across more services.

—

Core Concepts: Repository Pattern & Domain Logic Defined

What is the Repository Pattern (2026 Perspective)?

In 2026 the pattern has evolved from Fowler’s original PoEAA idea into a *domain‑driven* contract. It’s no longer “a generic CRUD wrapper”; it’s an interface that expresses **what the domain needs**, not **how the database works**.

What Constitutes “Automation Domain Logic”?

Automation logic isn’t just “run this SQL”. It includes scheduling policies, throttling rules, idempotency checks, and cross‑system coordination (e.g., a job that must create a record, enqueue a message, and start a Temporal workflow).

The Principle of Persistence Ignorance

Your domain entities should never know they’re being saved. They stay POCOs/POJOs, and the repository hides all `DbContext`, connection strings, or DynamoDB client details. This gives you two big wins: testability and the freedom to swap data stores without rewriting business code.

—

Step-by-Step: Clean Separation in Practice (2026)

Step 1: Defining Your Core Domain Entities & Aggregates

// .NET 8, C# 12
public sealed record JobId(Guid Value);
public sealed record ScheduledJob(
    JobId Id,
    string Name,
    DateTimeOffset NextRunUtc,
    string CronExpression,
    bool IsEnabled);

*Notice*: No EF attributes, no `DbSet`‑related magic. The aggregate root is `ScheduledJob`, and any invariants (e.g., a valid cron) are enforced in its factory method.

Step 2: Crafting the Repository Interface (Abstraction Layer)

**My take:** A repository should expose *behaviours* the domain cares about, not the raw tables.

// Design your contract in the Domain layer.
// See our deep‑dive tutorial on Designing Testable Interfaces in C# for more patterns.
public interface IScheduledJobRepository
{
    // Returns a lazy async stream – perfect for paging large job sets.
    IAsyncEnumerable<ScheduledJob> GetDueJobsAsync(DateTimeOffset utcNow, CancellationToken ct = default);

    Task<ScheduledJob?> GetByIdAsync(JobId id, CancellationToken ct = default);
    Task AddAsync(ScheduledJob job, CancellationToken ct = default);
    Task UpdateAsync(ScheduledJob job, CancellationToken ct = default);
    Task DeleteAsync(JobId id, CancellationToken ct = default);
}

Key points:

  • `IAsyncEnumerable` for scalable reads.
  • All methods return domain types, never EF entities.

Step 3: Implementing the Data Layer (Infrastructure)

EF Core 8 Implementation
// Infrastructure/EF/EFJobRepository.cs
using Microsoft.EntityFrameworkCore;

public sealed class EFJobRepository : IScheduledJobRepository
{
    private readonly AutomationDbContext _ctx;
    public EFJobRepository(AutomationDbContext ctx) => _ctx = ctx;

    public async IAsyncEnumerable<ScheduledJob> GetDueJobsAsync(
        DateTimeOffset utcNow,
        [EnumeratorCancellation] CancellationToken ct = default)
    {
        await foreach (var entity in _ctx.ScheduledJobs
            .AsNoTracking()
            .Where(j => j.NextRunUtc <= utcNow && j.IsEnabled)
            .AsAsyncEnumerable()
            .WithCancellation(ct))
        {
            yield return Map(entity);
        }
    }

    public async Task<ScheduledJob?> GetByIdAsync(JobId id, CancellationToken ct = default) =>
        Map(await _ctx.ScheduledJobs.FindAsync([id.Value], ct));

    public async Task AddAsync(ScheduledJob job, CancellationToken ct = default)
    {
        await _ctx.ScheduledJobs.AddAsync(Map(job), ct);
        await _ctx.SaveChangesAsync(ct);
    }

    public async Task UpdateAsync(ScheduledJob job, CancellationToken ct = default)
    {
        _ctx.ScheduledJobs.Update(Map(job));
        await _ctx.SaveChangesAsync(ct);
    }

    public async Task DeleteAsync(JobId id, CancellationToken ct = default)
    {
        var entity = await _ctx.ScheduledJobs.FindAsync([id.Value], ct);
        if (entity == null) return;
        _ctx.ScheduledJobs.Remove(entity);
        await _ctx.SaveChangesAsync(ct);
    }

    // Mapping helpers (domain ↔ EF)
    private static ScheduledJob Map(JobEntity e) =>
        new(new JobId(e.Id), e.Name, e.NextRunUtc, e.CronExpression, e.IsEnabled);
    private static JobEntity Map(ScheduledJob d) =>
        new() { Id = d.Id.Value, Name = d.Name, NextRunUtc = d.NextRunUtc,
                CronExpression = d.CronExpression, IsEnabled = d.IsEnabled };
}

*Error handling*: We bubble up EF exceptions; the domain service will translate them into domain‑specific errors (e.g., `ConcurrencyConflict`).

Dapper Implementation (for read‑heavy scenarios)
public sealed class DapperJobRepository : IScheduledJobRepository
{
    private readonly IDbConnection _cn;
    public DapperJobRepository(IDbConnection cn) => _cn = cn;

    public async IAsyncEnumerable<ScheduledJob> GetDueJobsAsync(
        DateTimeOffset utcNow,
        [EnumeratorCancellation] CancellationToken ct = default)
    {
        var sql = @"SELECT * FROM scheduled_jobs 
                    WHERE next_run_utc <= @Now AND is_enabled = 1
                    ORDER BY next_run_utc";
        var rows = await _cn.QueryAsync<JobDto>(new { Now = utcNow }, cancellationToken: ct);
        foreach (var r in rows)
            yield return Map(r);
    }

    // The rest mirrors the EF version, using Dapper's ExecuteAsync for mutations.
}
DynamoDB (NoSQL) Variant
public sealed class DynamoJobRepository : IScheduledJobRepository
{
    private readonly IAmazonDynamoDB _client;
    private readonly string _table = "ScheduledJobs";

    public DynamoJobRepository(IAmazonDynamoDB client) => _client = client;

    public async IAsyncEnumerable<ScheduledJob> GetDueJobsAsync(
        DateTimeOffset utcNow,
        [EnumeratorCancellation] CancellationToken ct = default)
    {
        var request = new QueryRequest
        {
            TableName = _table,
            IndexName = "NextRun-index",
            KeyConditionExpression = "NextRunUtc <= :now AND IsEnabled = :on",
            ExpressionAttributeValues = new Dictionary<string, AttributeValue>
            {
                {":now", new AttributeValue { N = utcNow.ToUnixTimeSeconds().ToString() }},
                {":on",  new AttributeValue { BOOL = true }}
            }
        };

        var response = await _client.QueryAsync(request, ct);
        foreach (var item in response.Items)
            yield return Map(item);
    }

    // AddAsync, UpdateAsync, DeleteAsync follow similar patterns.
}

Step 4: Writing the Domain Service & Application Logic

// Application/Services/JobSchedulingService.cs
public sealed class JobSchedulingService
{
    private readonly IScheduledJobRepository _repo;
    private readonly IMediator _mediator;          // for domain events
    private readonly ITemporalClient _temporal;    // Temporal workflow starter

    public JobSchedulingService(IScheduledJobRepository repo, IMediator mediator, ITemporalClient temporal)
    {
        _repo = repo;
        _mediator = mediator;
        _temporal = temporal;
    }

    public async Task<Result> ScheduleAsync(string name, string cron, CancellationToken ct = default)
    {
        // 1️⃣ Validate business invariants before touching persistence
        if (!CronExpression.IsValidExpression(cron))
            return Result.Failure("Invalid cron syntax.");

        var next = CronExpression.Parse(cron).GetNextOccurrence(DateTimeOffset.UtcNow);
        var job = new ScheduledJob(new JobId(Guid.NewGuid()), name, next, cron, true);

        // 2️⃣ Persist atomically with Unit of Work (see later)
        try
        {
            await _repo.AddAsync(job, ct);
        }
        catch (DbUpdateException ex) when (IsTransient(ex))
        {
            // Real error handling – retry with exponential back‑off
            return await RetryPolicy.ExecuteAsync(() => _repo.AddAsync(job, ct));
        }

        // 3️⃣ Emit domain event so other services can react (e.g., audit, webhook)
        await _mediator.Publish(new JobCreatedEvent(job.Id, job.Name), ct);

        // 4️⃣ Kick off a Temporal workflow for long‑running side‑effects
        await _temporal.StartWorkflowAsync<JobOrchestration>(job.Id.Value, new { job.Name }, ct);

        return Result.Success();
    }

    private static bool IsTransient(DbUpdateException ex) =>
        ex.InnerException is SqlException sql && sql.Number == -2; // timeout
}

Notice the *pure* domain rules (cron validation, next‑run calculation) live **before** any repository call. This order guarantees that we never persist an invalid job.

Step 5: Unit Testing the Domain in Isolation

// Tests/JobSchedulingServiceTests.cs
public sealed class JobSchedulingServiceTests
{
    private readonly Mock<IScheduledJobRepository> _repo = new();
    private readonly Mock<IMediator> _mediator = new();
    private readonly Mock<ITemporalClient> _temporal = new();
    private readonly JobSchedulingService _svc;

    public JobSchedulingServiceTests()
    {
        _svc = new JobSchedulingService(_repo.Object, _mediator.Object, _temporal.Object);
    }

    [Fact]
    public async Task ScheduleAsync_InvalidCron_ReturnsFailure()
    {
        var result = await _svc.ScheduleAsync("Backup", "not-a-cron");
        Assert.False(result.IsSuccess);
        Assert.Equal("Invalid cron syntax.", result.Error);
    }

    [Fact]
    public async Task ScheduleAsync_ValidJob_PersistsAndStartsWorkflow()
    {
        // Arrange – repository mock returns completed task.
        _repo.Setup(r => r.AddAsync(It.IsAny<ScheduledJob>(), It.IsAny<CancellationToken>()))
             .Returns(Task.CompletedTask);

        // Act
        var result = await _svc.ScheduleAsync("Report", "0 0 * * *");

        // Assert
        Assert.True(result.IsSuccess);
        _repo.Verify(r => r.AddAsync(It.Is<ScheduledJob>(j => j.Name == "Report"), It.IsAny<CancellationToken>()), Times.Once);
        _temporal.Verify(t => t.StartWorkflowAsync<JobOrchestration>(It.IsAny<Guid>(), It.IsAny<object>(), It.IsAny<CancellationToken>()), Times.Once);
    }
}

No database is involved; the test only cares about business rules and that the service *calls* the repository.

—

Real-World Code Example: Job Scheduler Automation

The PoEAA Flaw vs. Modern CQRS/ES Adaptations

Martin Fowler’s original PoEAA example treats the repository as a bag of CRUD methods. In a high‑throughput scheduler you’ll hit three problems:

  1. **Race conditions** when two workers pick the same `NextRunUtc`.
  2. **Eventual consistency** if you also write a message to a queue.
  3. **Scalability** concerns with heavy read‑paths.

Switching to a CQRS view, we separate the *read* side (`GetDueJobsAsync`) from the *write* side (`AddAsync`, `UpdateAsync`). Adding an Event Store (or Temporal) lets the write side publish a `JobScheduled` event that downstream services consume.

Implementing a `ScheduledJobRepository` Interface

(See the interface in **Step 2** – it already follows CQRS principles.)

The `JobSchedulingService` with Pure Domain Rules

(Implementation shown in **Step 4** – note the ordering of validation → persistence → event publishing.)

Concrete Repository with EF Core/Dapper/DynamoDB

(Full code snippets in **Step 3**.)

—

ConcernSimple DAO (direct EF)Clean Repository + UoWWhen to Prefer
Written by

’m Nilesh, a Software Development Engineer with 2+ years of experience, specializing in Go, JavaScript, Python, Docker, Kubernetes, Git, Jenkins, microservices, and system design (LLD/HLD), backed by a strong foundation in data structures and algorithms. Alongside my engineering journey, I bring 4+ years of hands-on experience in SEO, where I’ve worked extensively on content strategy, keyword research, technical SEO, and organic growth, helping products and businesses scale efficiently by aligning solid technology with search-driven performance.