TL;DR
Every time Taproot I/O saves something, it goes through the same series of steps, in the same order.
Those steps ask the same questions every time: Is this person allowed to do it? Is the information complete and valid? What should happen afterward?
If a step fails, nothing is left half saved. The whole change is undone.
Automatic checks make sure nothing in Taproot can save data without going through those steps. As of October 2026, nearly 400 different kinds of saves use them.
The structure is there for quality, and it also makes the AI tools' work predictable and easier to review.

Why I wanted structure
I wrote code by hand for over two decades, and the lesson that stuck with me is that structure improves quality. So when I set up the Taproot codebase for AI tooling, I made it follow a structured process for everything.
The biggest example is how data gets written. Every write operation goes through the same pipeline, which makes each one easier to test and easier to follow. It guarantees that every write follows a sane process that checks each step. Does the user have permission? Is all the required data there? What do we do with it once it has completed? The pipeline makes sure each of those gets considered, and tested, in the same way.
I call AI "AI tooling" on purpose. My slogan is "Good tools make hard work easy. Bad tools make easy work hard." A pipeline like this is one of the things that keeps AI tools on the good side of that line.
How the pipeline works
What is a command pipeline?
A command pipeline is one base class that owns the order of operations for a write. Each write is a command (a small record like CreateTag) plus a handler that overrides the stages it needs. The base class decides when each stage runs, opens and commits the transaction, stops early when a stage refuses, and logs the stage it's in. A handler can't reorder the stages or skip the transaction.
Here's the core of Execute, trimmed down to the control flow:
await Prepare(command, ctx, ct);
var authResult = await Authorize(command, ctx, ct);
if (authResult is not null) return authResult;
var errors = await Validate(command, ctx, ct);
if (errors.Length > 0) return new CommandResult<TResult>.ValidationFailed(errors);
await using (var transaction = await unitOfWork.BeginTransactionAsync(ct))
{
// ValidateInTransaction runs first, then:
result = await Handle(command, ctx, ct);
// the audit entry and outbox messages are tracked here
await unitOfWork.SaveChangesAsync(ct);
await unitOfWork.CommitTransactionAsync(ct);
}
// AfterCommit runs hereWhat does each stage do?
Prepare loads the records the command touches into a shared
CommandContext, so later stages can use them.Authorize checks domain permissions and returns
UnauthorizedorNotFoundto stop.Validate checks the input and the rules that don't need locks.
ValidateInTransaction checks rules that need row locks, like quotas and uniqueness, inside the transaction.
Handle makes the change. It's the only stage every handler must write.
BuildAuditEntry records who did what. The pipeline stamps the actor itself, so a handler can't get it wrong.
BuildOutboxMessages queues durable side effects, like image processing, in the same transaction as the change.
Commit is the framework's job.
AfterCommit handles things that can't be rolled back, like cache busts. Network I/O goes here, never in Handle.
Anonymous writes, like a public form submission, get one more check before Prepare: a public-write guard that can refuse the request before any handler code runs.
What does a handler look like?
Creating a tag on a site is a typical small handler. Authorize asks one question, whether the signed-in user has the SiteTagsManage permission on that site:
protected override async Task<CommandResult<ITag>?> Authorize(
CreateTag command, CommandContext ctx, CancellationToken ct)
{
var session = ctx.TryGet(ContextKeys.Session);
if (session is null)
return new CommandResult<ITag>.Unauthorized("Authentication required.");
var decision = await authorizationEvaluator.AuthorizeSitePermissionAsync(
session.UserId, command.SiteId, PermissionKeys.SiteTagsManage, ct: ct);
if (!decision.Allowed)
return new CommandResult<ITag>.Unauthorized(/* denial reason */);
return null; // authorized
}Validate checks that the tag name is present, valid and unique within the site. That includes a friendly error when two names would produce the same URL, so "Hiking" and "hiking" can't both claim /tags/hiking. Handle adds the tag and returns it, and BuildAuditEntry records a CreateTag entry. The whole file is about 160 lines, and most of that is the validation rules.
How does the API call it?
The gRPC service builds the command from the request and hands it off. It never touches the database:
var command = new CreateTag(
RequestValidation.RequireGuid(request.SiteId, "site ID"),
request.TagName,
request.HasGeneratePage ? request.GeneratePage : null);
var tag = (await createSiteTag.Execute(command)).Unwrap();
return MapTag(tag);Execute returns a CommandResult: Success, ValidationFailed, Unauthorized or NotFound. Unwrap() turns each failure into the matching gRPC status, so every endpoint reports a validation failure or a missing record the same way. That pairs with the single server interceptor described in the contract-first post, which handles logging and turns exceptions into errors for every call.
How do we keep writes from skipping it?
A rule in AGENTS.md says all mutations go through the pipeline, but rules in a file aren't enough on their own. xUnit architecture tests use reflection to check that no API entry point injects the database context or the unit of work, and a regex scan of their source bans direct writes:
(new Regex(@"\bTaprootContext\b"), "TaprootContext"),
(new Regex(@"\bExecuteUpdateAsync\s*\("), "ExecuteUpdateAsync"),
(new Regex(@"\bExecuteDeleteAsync\s*\("), "ExecuteDeleteAsync"),
(new Regex(@"\bSaveChangesAsync\s*\("), "SaveChangesAsync"),If a service tries to save something directly, the test suite fails. There's more on that approach in how we enforce coding standards.
Why does this help AI tooling?
A new write has a known shape: a command record, a handler with the same overrides, a one-line call from the service, and tests for each stage. That gives an AI tool much less to invent. When Claude Code or OpenAI Codex adds a write, it fills in stages instead of designing a flow, and the other tool reviews it against the same checklist: is Authorize asking the right permission, are the locking rules in ValidateInTransaction, is anything doing network I/O inside Handle?
The tests follow the same shape. The pipeline itself has 15 tests for stage order, short-circuiting, rollback and audit tracking, and there are 152 handler test files that exercise real handlers stage by stage. Service tests only cover request mapping and the auth gate, so each behavior is tested in one place. We haven't measured whether this makes AI-written code better than it would be without it. What we can say is that every write gets the same questions asked of it, and a missing answer is easy to spot in review.
What does it cost?
Ceremony. A simple write is a command record, a handler class and tests, and the tag handler is about 160 lines for something a direct database call could do in ten. The base class is around 640 lines, and nearly every handler actually derives from CoordinatedCommandHandler, a subclass that coordinates saves across Taproot's four bounded-context databases. That's one more concept to learn before you can add a write. For a write with no real permission or validation logic, the pipeline can feel like paperwork.
How would you start smaller?
You don't need nine stages on day one. A base class with Authorize, Validate and Handle inside one transaction covers most of the value, as long as it's the only way to write. Add an audit entry when you need to know who changed what, an outbox when side effects have to survive a crash, and an architecture test as soon as there's more than one person (or AI tool) writing code.