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.
New tools help, but do not remove the need for sound design
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.
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.
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.
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”.
Clicked a button, submitted a form or selected an option.
An explicit intention, input data and an attempt identifier.
A fact produced after the domain rules have been satisfied.
A typed function used without knowing its implementation.
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.
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));
}
}
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.
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();
}
}
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.
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.
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();
}
}
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.
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.
Id PK
CustomerId FK
Status
Version concurrency
Id PK
WorkOrderId FK
ProductId FK
Quantity CHECK > 0
Id PK
TenantId FK
IdempotencyKey UNIQUE
ResultJson
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();
});
}
Add the column as nullable or with a safe default.
Populate data in batches and monitor locks and execution time.
Switch writes and reads to the new contract.
Remove the old column only after verifying live traffic.
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.
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;
}
Two operators read the same record.
The database increases the version to 8.
The update condition does not match.
The UI refreshes the state instead of overwriting the change.
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.
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));
Quickly checks state transitions and invariants.
Runs EF Core against a real provider and checks indexes and the transaction.
Confirms statuses 200, 400, 403 and 409 and a stable Problem Details response.
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
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
Environment ready. Start a controlled run.
Before merging, ask the questions the happy path does not reveal
Who owns the decision?
Will the same rule apply to the API, worker and import?
What happens on repetition?
Will the operation return the same result or create a second effect?
What will two processes do at once?
Will the database resolve the conflict atomically, and will the UI explain the 409?
What might fail between steps?
Will the transaction and outbox let work resume without manual cleanup?
How will we change the model without downtime?
Does the migration support two application releases during rollout?
How will we prove the result?
Will tests and telemetry show a durable effect without exposing data?
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.