From component to transaction: actions, relationships and databases without hidden magic
Follow a click through an API contract and domain model to transactions, optimistic concurrency, outbox and an E2E test against a relational database.
The full article is available below. Enable JavaScript to use quizzes and simulations.
A system workshop: from click to COMMIT
A button does not save data. It starts a contract owned by the whole system.
Difficult bugs often arise between components: the UI considers an operation finished, the API is just starting it, and the database rejects the write because another user changed the same record.
We will follow one concrete action: an operator clicks “Start fulfillment”. We will examine the component, HTTP request, application layer, domain model and relational database. Then we will deliberately break the flow and identify where the system must say no.
Flow map
Five responsibility boundaries
Component
The UI translates intent; it does not act as a database
A component knows its form, button and user message. It should not know table-write order or decide whether the company has quota remaining. Those rules must also apply when the same API is called by a mobile app, integration or background job.
The component below prevents accidental double-clicks, sends the record version and updates visible state only after the server responds. Animation may react immediately; the business result is confirmed by the API.
export class WorkOrderActions {
private readonly api = inject(WorkOrdersApi);
readonly pending = signal(false);
readonly error = signal<string | null>(null);
async start(order: WorkOrderView): Promise<void> {
if (this.pending()) return;
this.pending.set(true);
this.error.set(null);
try {
const result = await firstValueFrom(
this.api.start(order.id, {
expectedVersion: order.version,
requestId: crypto.randomUUID()
})
);
order.status = result.status; // stan potwierdzony przez API
order.version = result.version;
} catch (error) {
this.error.set(mapProblemDetails(error));
} finally {
this.pending.set(false);
}
}
}
Architectural decision
The user clicks twice because the network is slow. What do you do?
Disable the button and consider the problem solved
Block the UI and enforce idempotency in the API and database
Choose an answer and see the consequence.
Action
The endpoint should be thin, but its contract must be deliberate
The endpoint owns the protocol boundary: authentication, authorization, valid input, error mapping and response codes. Dozens of business-logic lines here lead to different versions of the same rule in the API, worker and administration panel.
app.MapPost("/api/work-orders/{id:guid}/start", async (
Guid id,
StartWorkOrderRequest body,
ClaimsPrincipal user,
StartWorkOrderHandler handler,
CancellationToken ct) =>
{
var tenantId = user.RequireTenantId();
var result = await handler.HandleAsync(
new StartWorkOrderCommand(
tenantId,
id,
body.ExpectedVersion,
body.RequestId),
ct);
return result.Match(
started => Results.Ok(started),
conflict => Results.Conflict(new {
code = "work_order_changed",
message = "The work order has changed. Refresh the data."
}),
invalid => Results.ValidationProblem(invalid.Errors));
})
.RequireAuthorization("WorkOrderOperator")
.RequireRateLimiting("work-order-write");
Missing requestId, an invalid version or data outside the contract.
The user is logged in but cannot start this work order.
Someone changed the record after it was read, or this intent has already been handled.
The response contains a new version and database-confirmed status.
Domain model
A business rule has one place where it can say no
Suppose a work order can start only after scope approval and before completion. This rule belongs in the model because it describes a permitted state transition, rather than in a controller, component or SQL query.
public sealed class WorkOrder
{
public Guid Id { get; private set; }
public WorkOrderStatus Status { get; private set; }
public bool ScopeApproved { get; private set; }
public int Version { get; private set; }
public DomainResult Start(DateTimeOffset now)
{
if (!ScopeApproved)
return DomainResult.Invalid("scope_not_approved");
if (Status == WorkOrderStatus.Completed)
return DomainResult.Invalid("work_order_completed");
if (Status == WorkOrderStatus.InProgress)
return DomainResult.NoChange();
Status = WorkOrderStatus.InProgress;
Version += 1;
AddDomainEvent(new WorkOrderStarted(Id, now));
return DomainResult.Changed();
}
}
Notice NoChange(). Repeating the same valid intent need not be an error. It can return the current state if the system can prove it is the same operation. Retrying after a timeout then avoids manual data cleanup.
Relational database
Relationships are the data's final line of defense
Application validation produces readable errors, but the database must ensure two concurrent operations cannot violate uniqueness. The schema should express invariants: foreign keys, required values, ranges, uniqueness and concurrency tokens.
Id PK
TenantId FK
Status
Version
Id PK
WorkOrderId FK
RequestId UNIQUE
ResultJson
Id PK
AggregateId
Type
ProcessedAt
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 });
});
modelBuilder.Entity<WorkOrderRequest>(entity =>
{
entity.HasKey(x => x.Id);
entity.HasIndex(x => new { x.TenantId, x.RequestId })
.IsUnique();
entity.HasOne(x => x.WorkOrder)
.WithMany()
.HasForeignKey(x => x.WorkOrderId)
.OnDelete(DeleteBehavior.Restrict);
});
}
Two requests race
Both processes checked that requestId was absent. What prevents duplication?
Another AnyAsync() check before saving
A unique index on TenantId + RequestId
Choose a mechanism that remains reliable under concurrency.
Transaction
COMMIT marks the point when the saved change can be called complete
If a change includes a work-order record, an idempotency-attempt record and an outbox message, they must all commit together or not at all. One
SaveChangesAsync is transactional for typical relational providers. When the flow requires multiple writes, control the transaction explicitly and test it with the actual provider.
public async Task<StartWorkOrderView> HandleAsync(
StartWorkOrderCommand command,
CancellationToken ct)
{
var previous = await db.WorkOrderRequests.SingleOrDefaultAsync(
x => x.TenantId == command.TenantId
&& x.RequestId == command.RequestId,
ct);
if (previous is not null)
return previous.ReadResult();
var order = await db.WorkOrders.SingleAsync(
x => x.Id == command.WorkOrderId
&& x.TenantId == command.TenantId,
ct);
if (order.Version != command.ExpectedVersion)
throw new WorkOrderConflictException();
order.Start(clock.UtcNow);
var view = StartWorkOrderView.From(order);
db.WorkOrderRequests.Add(
WorkOrderRequest.Completed(command, view));
db.OutboxMessages.Add(
OutboxMessage.From(new WorkOrderStarted(order.Id, clock.UtcNow)));
await db.SaveChangesAsync(ct);
return view;
}
This still does not mean email was sent. It means the database saved a durable intention to send it. After COMMIT, a worker reads the outbox, performs the external operation and marks the message processed. A mail-provider outage does not erase the record.
External effect
When should the “fulfillment started” email be sent?
Before SaveChanges, so the customer receives it sooner
After COMMIT through a transactional outbox
Choose the point that avoids false confirmations.
Concurrency
409 honestly reports that a decision used an old state
Operator A opens version 7. Operator B changes the scope and saves version 8. If A starts fulfillment from the old view, the system must not silently overwrite B's decision. A concurrency token makes the update succeed only if the version still matches.
Both interfaces show the same state.
The database advances to version 8.
UPDATE finds no matching version.
The UI refreshes data and asks for a new decision.
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new WorkOrderConflictException(
"The work order was changed by another operator.");
}
E2E test
Check the operation's effects, not only a 200 response
A flow test should call the real endpoint through authorization, handler and relational provider, then inspect table state as well as JSON. A database mock will not expose an index violation, isolation behavior or incorrect type mapping.
[Fact]
public async Task Start_is_idempotent_and_creates_one_outbox_message()
{
await using var app = new ApiFactory()
.WithPostgresContainer()
.WithOperator("tenant-a");
var client = app.CreateClient();
var requestId = Guid.NewGuid();
var first = await client.PostAsJsonAsync(
$"/api/work-orders/{Seed.OrderId}/start",
new { expectedVersion = 1, requestId });
var retry = await client.PostAsJsonAsync(
$"/api/work-orders/{Seed.OrderId}/start",
new { expectedVersion = 1, requestId });
first.EnsureSuccessStatusCode();
retry.EnsureSuccessStatusCode();
await app.InDatabase(async db =>
{
Assert.Equal(1, await db.WorkOrderRequests.CountAsync());
Assert.Equal(1, await db.OutboxMessages.CountAsync());
Assert.Equal(WorkOrderStatus.InProgress,
await db.WorkOrders.Select(x => x.Status).SingleAsync());
});
}
| Scenario | Expected HTTP | Database state | UI response |
|---|---|---|---|
| First valid start | 200 | Status + request + outbox | Shows the new state |
| Retry of the same requestId | 200 | No second write | Shows the same result |
| Old record version | 409 | No change | Refreshes and explains the conflict |
| Permission denied | 403 | No change | Does not disclose operation data |
| Email-provider failure | 200 | Outbox awaits retry | Does not undo the successful operation |
Acceptance simulation
Run the change through the full pipeline
Run the flow test
Ready to test: idempotency, version conflicts, transactions and effects after COMMIT.
Definition of Done
Healthy layer relationships let you answer yes
Does it show waiting and failure without pretending success?
Does the request carry an explicit requestId and version token where required?
Does the rule apply regardless of who invokes the operation?
Do foreign keys, indexes and concurrency tokens protect data during races?
Do emails, webhooks and queued work start only after confirmed persistence?
Does the test check both the response and actual relational-database effects?
The central takeaway
Layers give each decision an owner.
The component handles communication with the user; the endpoint handles protocol and permissions; the domain model handles permitted changes; the database handles atomicity and relationships; and the outbox supports crossing the system boundary safely. Explicit roles let the team reproduce, fix and test defects instead of chasing them through the code.
The quiz below covers these exact problems. Scoring happens on the server. Earn at least 4 out of 5 and verify your email to receive a one-time discount code for your first paid technical audit.