From action to database: an interactive journey through delegates, events and architecture

An interactive workshop from a component click to durable storage: actions, delegates, hierarchy, relationships, EF Core, PostgreSQL, outbox, concurrency and E2E tests.

The full article is available below. Enable JavaScript to use quizzes and simulations.

An interactive architecture workshop

We will connect classes and databases by building one flow that has to survive production.

In this lesson, you will build a work-order module: from a click in the interface, through an action and a delegate, to a domain rule, transaction, database relationships and an event sent after persistence. Along the way, you will make six architectural decisions, arrange a pipeline in the correct order and run an acceptance simulation.

This material has three layers. Beginners will see what calls what. Mid-level developers will understand where each layer's responsibility ends. Senior developers will find checkpoints for concurrency, idempotency, observability and model evolution.

The example project

System: WorkFlow

A customer creates a work order. An operator accepts it. The system records history, publishes an event and updates the view. It sounds simple until the user clicks twice, the database responds slowly or a worker restarts.

Goal: consistent persistence and a clear result
Risk: duplicates, lost updates, false success
Stack: Angular, C# 14, ASP.NET Core, EF Core 10, PostgreSQL
Acceptance: contract, integration and E2E tests
New features that matter

New tools help, but do not remove the need for sound design

C# 14

Extension members and simpler lambdas

The language now makes small behaviours easier to express clearly. That does not justify hiding domain logic in a collection of unrelated extensions.

EF Core 10 LTS

A better model, named filters and new LINQ operators

LeftJoin, named filters and improved JSON support make work easier. You still need to read the generated SQL, the query plan and the effects of migrations.

AI in the team

A faster draft, the same responsibility

A model can suggest an entity, handler and test. But it does not know all the company's invariants, production data or the cost of a bad decision.

A rule for 2026: use new features where they simplify the contract. Do not use them merely to make an architecture diagram look modern.

Level 01

Mental model

Action, command, event and delegate are not four names for the same thing

A user action happens in the interface: a click, form submission or dragged item. A command describes an intention to change something: “accept work order 42”. An event describes something that has already happened: “work order 42 was accepted”. A delegate is a typed contract for behaviour that can be passed as a value.

The key difference concerns time. A command can be rejected. We do not reject a domain event because it describes a fact recorded by the model. A delegate is neither a fact nor an intention. It simply says: “at this point, I need a function with this input and this result”.

UI action What did the person do?

Clicked a button, submitted a form or selected an option.

Command What should the system attempt to do?

An explicit intention, input data and an attempt identifier.

Domain event What has already happened?

A fact produced after the domain rules have been satisfied.

Delegate What behaviour are we injecting?

A typed function used without knowing its implementation.

Decision 1/6

The customer clicked “Accept”. Can the interface immediately show “saved”?

Yes. The click is enough evidence.

No. It needs confirmation from the server first.

Choose an answer. You can revise every decision.

Level 02

The interface boundary

A component captures intent. It should not coordinate the business process

The component knows the form fields, button availability, loading state and message. It should not need to know that the backend writes to three tables, publishes a message and updates a projection. If the browser controls the order of those steps, closing the tab halfway through leaves the system in an intermediate state.

type AcceptState =
  | { kind: 'idle' }
  | { kind: 'sending' }
  | { kind: 'accepted'; version: number }
  | { kind: 'conflict'; message: string }
  | { kind: 'failed'; message: string };

async accept(order: WorkOrderView): Promise<void> {
  if (this.state().kind === 'sending') return;

  this.state.set({ kind: 'sending' });

  try {
    const result = await firstValueFrom(this.api.accept(order.id, {
      expectedVersion: order.version,
      idempotencyKey: crypto.randomUUID()
    }));

    this.state.set({
      kind: 'accepted',
      version: result.version
    });
  } catch (error) {
    this.state.set(mapAcceptError(error));
  }
}
Decision 2/6

The component makes separate calls to save the order, reduce the allowance and send an email. What do you change?

I replace three requests with one business command.

I keep the three requests and run Promise.all.

Decide who should coordinate the operation.

Level 03

Delegates and events

A delegate carries behaviour. An event limits who can invoke it

Delegates are typed and can be passed like data. They work well as small strategies, predicates, pipeline steps and callbacks. An event uses a delegate but imposes an important boundary: external code can subscribe or unsubscribe, while only the event's owner can raise it.

When a recipient should receive a question and return one answer, use a delegate or interface. When an owner publishes a fact to zero, one or many listeners, an event is more natural. When communication crosses a process boundary or must survive a restart, you need a durable message, a broker and an idempotent consumer.

public delegate ValueTask<StepResult> WorkOrderStep(
    WorkOrderContext context,
    CancellationToken cancellationToken);

public sealed class WorkOrderPipeline(IEnumerable<WorkOrderStep> steps)
{
    public async ValueTask<StepResult> ExecuteAsync(
        WorkOrderContext context,
        CancellationToken ct)
    {
        foreach (var step in steps)
        {
            var result = await step(context, ct);
            if (!result.CanContinue)
                return result;
        }

        return StepResult.Success();
    }
}
Pipeline Calls the contract
01Validationis the input complete?
02Authorisationmay this actor change the state?
03Ruledoes the domain allow the transition?
Decision 3/6

A pricing engine needs a small discount algorithm that varies by campaign. What do you choose?

A typed delegate passed to the calculator.

Ten classes inheriting from AbstractDiscountBase.

Choose the smallest contract that actually solves the problem.

Level 04

Domain hierarchy

A hierarchy should reveal rules, not the folder structure

Start with behaviour. A work order can move from draft to accepted only if it has at least one line, a valid owner and the current version. The entity guards the transition. The handler loads data and saves the result. The repository does not make the business decision.

WorkFlow module Business boundary
Application AcceptWorkOrderHandler coordinates the use case
Domain WorkOrder aggregate protects invariants
Infrastructure EF Core + Outbox provides persistence
Command Result Entity Value Object DbContext Worker
public sealed class WorkOrder
{
    private readonly List<IDomainEvent> _events = [];

    public WorkOrderId Id { get; private set; }
    public WorkOrderStatus Status { get; private set; }
    public int Version { get; private set; }
    public IReadOnlyList<WorkOrderLine> Lines => _lines;

    public Result Accept(UserId actor, DateTimeOffset now)
    {
        if (_lines.Count == 0)
            return Result.Invalid("work_order_has_no_lines");

        if (Status is WorkOrderStatus.Accepted)
            return Result.NoChange();

        if (Status is not WorkOrderStatus.Draft)
            return Result.Conflict("invalid_status_transition");

        Status = WorkOrderStatus.Accepted;
        Version++;
        _events.Add(new WorkOrderAccepted(Id, actor, now, Version));

        return Result.Changed();
    }
}
Decision 4/6

Where should the rule “do not accept an order without lines” live?

Only in the component, using disabled.

In the domain model, also reflected in the UI.

Identify the boundary that another API client cannot bypass.

Level 05

The relational database

The code model tells a story. The database schema ensures that story is possible

A relational model should not mirror every object. It should persist the facts, relationships and constraints the system must protect. A work order has many lines. Every change attempt has a unique idempotency key. Every outbox message identifies the aggregate that produced it.

The database resolves races between concurrent writes. C# validation provides a better message, but a unique index, foreign key and concurrency token are the final line of defence when a new integration or data import enters the system.

WorkOrders Id PK CustomerId FK Status Version concurrency
WorkOrderLines Id PK WorkOrderId FK ProductId FK Quantity CHECK > 0
CommandReceipts Id PK TenantId FK IdempotencyKey UNIQUE ResultJson
OutboxMessages Id PK AggregateId EventType ProcessedAt NULL
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<WorkOrder>(entity =>
    {
        entity.HasKey(x => x.Id);
        entity.Property(x => x.Version).IsConcurrencyToken();
        entity.HasIndex(x => new { x.TenantId, x.Status });

        entity.HasMany(x => x.Lines)
            .WithOne()
            .HasForeignKey(x => x.WorkOrderId)
            .OnDelete(DeleteBehavior.Cascade);
    });

    modelBuilder.Entity<CommandReceipt>(entity =>
    {
        entity.HasIndex(x => new { x.TenantId, x.IdempotencyKey })
            .IsUnique();
    });
}
Safe migration Expand

Add the column as nullable or with a safe default.

Backfill Migrate data

Populate data in batches and monitor locks and execution time.

Switch Deploy code

Switch writes and reads to the new contract.

Contract Remove old

Remove the old column only after verifying live traffic.

Decision 5/6

Two processes ran AnyAsync and neither found the idempotency key. What stops a duplicate?

Another AnyAsync immediately before SaveChanges.

A unique index and write-conflict handling.

Choose a mechanism that also works under real concurrency.

Level 06

Transaction and outbox

COMMIT confirms the state. The outbox carries the fact beyond the database boundary

The work-order write, idempotency receipt and outbox record must belong to the same transaction. Email, webhooks and broker publication should not be part of it: an external provider cannot participate in a PostgreSQL transaction. We therefore record the intention to deliver, execute COMMIT, and let a worker deliver the message with retries and deduplication.

public async Task<AcceptWorkOrderResult> Handle(
    AcceptWorkOrder command,
    CancellationToken ct)
{
    var receipt = await db.CommandReceipts.SingleOrDefaultAsync(
        x => x.TenantId == command.TenantId
          && x.IdempotencyKey == command.IdempotencyKey,
        ct);

    if (receipt is not null)
        return receipt.ReadResult<AcceptWorkOrderResult>();

    var order = await db.WorkOrders
        .Include(x => x.Lines)
        .SingleAsync(x => x.Id == command.WorkOrderId, ct);

    if (order.Version != command.ExpectedVersion)
        throw new WorkOrderConflictException();

    order.Accept(command.ActorId, clock.UtcNow);
    var result = AcceptWorkOrderResult.From(order);

    db.CommandReceipts.Add(CommandReceipt.From(command, result));
    db.OutboxMessages.AddRange(order.DequeueEvents().Select(OutboxMessage.From));

    await db.SaveChangesAsync(ct);
    return result;
}
T0Version 7

Two operators read the same record.

T1Operator B saves

The database increases the version to 8.

T2Operator A submits 7

The update condition does not match.

T3The API returns 409

The UI refreshes the state instead of overwriting the change.

Decision 6/6

When may the “work order accepted” message be sent?

After COMMIT, through a worker reading the transactional outbox.

Before SaveChanges, to make it faster.

Choose the moment that does not create false facts.

Level 07

Observability and tests

A system is ready when you can reconstruct its decision

A log saying “something failed” is not enough. Every operation should have a correlation ID, use-case name, anonymous actor identifier, aggregate version and timings for individual stages. Do not record full payloads, tokens or sensitive data. Telemetry should help reconstruct the flow without turning logs into a second customer database.

using var activity = telemetry.StartActivity("work_order.accept");
activity?.SetTag("work_order.id", command.WorkOrderId);
activity?.SetTag("work_order.expected_version", command.ExpectedVersion);
activity?.SetTag("operation.idempotency_hash",
    hasher.ShortHash(command.IdempotencyKey));

var result = await handler.Handle(command, ct);

metrics.WorkOrdersAccepted.Add(1,
    new("result", result.Kind),
    new("source", command.Source));
01 Domain test

Quickly checks state transitions and invariants.

02 Integration test

Runs EF Core against a real provider and checks indexes and the transaction.

03 Contract test

Confirms statuses 200, 400, 403 and 409 and a stable Problem Details response.

04 E2E test

Follows the interface action through to a durable effect and telemetry record.

Game: build the flow

Select the tiles in the order a safe operation passes through the system

Progress 0/7

Domain rulethe aggregate makes the decision

A user actionan intention is formed

Outbox workerdelivers the effect after COMMIT

Application handlercoordinates the use case

New UI stateshows the confirmed result

HTTP contractauthorises and validates the input

Database transactionstate, receipt and outbox together

Start where the user expresses an intention.

Start again

Deployment simulator

Take the change through production acceptance

The simulation checks the contract, authorisation, version conflicts, key uniqueness, atomic outbox, worker retries and view updates.

Run the full test

UIAPIDomainDBOutboxE2E

Environment ready. Start a controlled run.

The senior developer's perspective

Before merging, ask the questions the happy path does not reveal

Boundary

Who owns the decision?

Will the same rule apply to the API, worker and import?

Retry

What happens on repetition?

Will the operation return the same result or create a second effect?

Race

What will two processes do at once?

Will the database resolve the conflict atomically, and will the UI explain the 409?

Failure

What might fail between steps?

Will the transaction and outbox let work resume without manual cleanup?

Evolution

How will we change the model without downtime?

Does the migration support two application releases during rollout?

Evidence

How will we prove the result?

Will tests and telemetry show a durable effect without exposing data?

A reference for everyday work

Match the tool to the type of problem

Need The smallest sensible mechanism A dangerous shortcut Acceptance evidence
Pass a small behaviour delegate / Func / strategy a hierarchy with no state or rules a test of the function's input and result
Notify multiple listeners within a process an event with an explicit owner a global static event bus a test of subscriptions and absence of leaks
Carry a fact across a process boundary outbox + broker + idempotent consumer publish before COMMIT a retry and deduplication test
Protect a data relationship FK, UNIQUE, CHECK, concurrency token validation only in the UI an integration test against the provider
See the entire flow trace + metrics + safe logs full payload in every log correlation ID from UI to worker

What to take into your project

Good architecture is about explicit decisions and a verifiable result, not the number of layers.

An action starts the conversation. A command carries intent. A handler coordinates the use case. The domain says “yes” or “no”. The database completes the consistency guarantee. The outbox crosses the system boundary safely. The UI shows the result once it has evidence.

If you are starting out, return to the concept map and arrange the flow again. If you are a mid-level developer, try writing contracts for your own use case. If you are a senior responsible for a system, review retries, concurrency, migrations and telemetry trails with your team.

Below you will find a final quiz scored on the server. It does not test your ability to memorise definitions. It tests whether you can choose a solution that behaves honestly when production stops resembling the local happy path.

Sources and further learning