Dual Controller Pattern
Formation’s API uses a dual-controller pattern for every entity: one controller handles reads via OData, another handles writes via a LiteBus command mediator. The two share a URL prefix but split along read/write lines.
This page explains the pattern in depth: why it exists, how the two halves compose, and the handful of footguns that come with it.
Table of Contents
Section titled “Table of Contents”- Why Two Controllers
- The Read Side (OData)
- The Write Side
- Authentication and Authorisation
- Error Handling —
ToErrorResult() - Where Each Kind of Logic Lives
- Gotchas
Why Two Controllers
Section titled “Why Two Controllers”OData query semantics and REST write semantics pull in different directions:
- Reads want query composition:
$filter,$expand,$select,$orderby,$top,$skip,$count,$search. These are translated to EF Core expressions byMicrosoft.AspNetCore.ODataand returned as-is. A singleGetmethod supports many shapes. - Writes want intent: “create this scheme”, “patch this field”, “replace this occupier”. Validation, domain rules, transactions, and event publishing must all happen before a response is returned. OData’s data-service abstraction fights this.
So Formation splits them. The read controller inherits ODataController and participates in the OData pipeline. The write controller inherits a thin custom base (EntityWriteControllerBase<TEntity>) that delegates everything to LiteBus command handlers. They share a URL prefix — e.g. /Schemes — so the HTTP surface looks unified even though the implementation halves are wildly different.
┌──────────────────────────────┐ │ GET /Schemes?$filter=… │ │ GET /Schemes?$search=mfg │HTTP ─────────▶ │ GET /Schemes/{key} │ ─▶ SchemesController (OData) │ │ │ POST /Schemes │ │ PATCH /Schemes/{key} │ │ PUT /Schemes/{key} │ ─▶ SchemesWriteController (LiteBus) │ DELETE /Schemes/{key} │ └──────────────────────────────┘The routing is resolved per-verb: OData registers the GET route on collection URLs, the write controller registers the mutating verbs on the same path.
The Read Side (OData)
Section titled “The Read Side (OData)”Routing and Base Class
Section titled “Routing and Base Class”Read controllers inherit Microsoft.AspNetCore.OData.Routing.Controllers.ODataController. They do not carry [Route] attributes — the route name is derived from the EDM model registered at startup:
// src/services/api/app/app.api/Controllers/SchemesController.cs:15-26public class SchemesController : ODataController{ private readonly FormationDbContext _context; private readonly ISchemeSearchService _searchService;
public SchemesController( FormationDbContext context, ISchemeSearchService searchService) { _context = context; _searchService = searchService; }A single public Get method handles both collection (GET /Schemes) and keyed (GET /Schemes(1)) shapes — OData routing dispatches on URL form. The signature takes an ODataQueryOptions<TEntity> plus optional non-OData query parameters used by the search pipeline:
// src/services/api/app/app.api/Controllers/SchemesController.cs:31-34public async Task<ActionResult<Scheme>> Get( ODataQueryOptions<Scheme> options, [FromQuery] string? searchFields = null, [FromQuery(Name = "$search")] string? search = null)Query-Option Flow
Section titled “Query-Option Flow”Each query option arrives via the ODataQueryOptions<Scheme> parameter, but Formation applies them in a carefully ordered pipeline rather than a single options.ApplyTo(query) call:
// src/services/api/app/app.api/Controllers/SchemesController.cs:41-112var baseQuery = _context.Schemes .AsNoTracking() .ApplyODataExpansions(options); // 1. $expand → EF Include()
var limit = options.Top?.Value ?? 60;var skip = options.Skip?.Value ?? 0;
var (filteredQuery, idFilterConsumed) = baseQuery.TryApplyEncodedIdFilter( // 2. $filter=Id eq '<encoded>' options, s => s.SchemeId);baseQuery = filteredQuery;
if (!string.IsNullOrWhiteSpace(search)){ // 3. Full-text search pipeline (see technical/search/) var parsedQuery = SearchQueryParser.Parse(search); var fields = SchemeSearchOptions.Parse(searchFields, parsedQuery); baseQuery = _searchService.ApplyFieldFilters(baseQuery, parsedQuery, fields); if (!string.IsNullOrWhiteSpace(parsedQuery.GeneralSearchText)) { var terms = new SearchTokenizer(parsedQuery.GeneralSearchText).Terms(); baseQuery = _searchService.ApplyFullTextSearch( baseQuery, parsedQuery.GeneralSearchText, terms, fields); } baseQuery = _searchService.ApplySearchIncludes(baseQuery, fields);}
// 4. Hand back to OData for Filter / Count / OrderBy (only)var ignore = AllowedQueryOptions.All & ~AllowedQueryOptions.Filter & ~AllowedQueryOptions.Count & ~AllowedQueryOptions.OrderBy;if (idFilterConsumed) ignore |= AllowedQueryOptions.Filter;baseQuery = options.ApplyTo(baseQuery, ignore) as IQueryable<Scheme>;
// 5. Default ordering + manual paging + split queryif (options?.OrderBy == null) baseQuery = baseQuery.OrderBy(s => s.SchemeId);baseQuery = baseQuery .Skip(skip) .Take(limit) .AsSplitQuery();
var schemes = await baseQuery.ToListAsync();
// 6. Load polymorphic collections (Notes/Tags/ExternalLinks) separatelyawait _context.LoadPolymorphicCollectionsAsync(…);await schemes.LoadLightweightMarketBoundariesAsync(_context.Set<SchemeMarketBoundary>());
return Ok(schemes);The split exists because no single primitive handles all the requirements. $expand needs to become EF Include(…) calls; $filter=Id eq '<encoded>' needs to be rewritten before OData sees it; $search is a Formation concept rather than an OData one; $top/$skip have to be applied after $orderby but before AsSplitQuery() or EF’s row-number translation breaks.
AllowedQueryOptions — The Ignore-List Footgun
Section titled “AllowedQueryOptions — The Ignore-List Footgun”The most common way to misread the above code is to assume options.ApplyTo(query, ignore) is an allow-list. It isn’t.
From the Microsoft docs for ODataQueryOptions.ApplyTo(query, ignoreQueryOptions):
ignoreQueryOptions — The query parameters that are already applied in queries.
Flags set in the bitmask are skipped by ApplyTo. Flags cleared are applied by ApplyTo. Formation’s mask:
var ignore = AllowedQueryOptions.All & ~AllowedQueryOptions.Filter & ~AllowedQueryOptions.Count & ~AllowedQueryOptions.OrderBy;reads as “ignore everything except Filter, Count, OrderBy”. So:
| Query option | What happens |
|---|---|
$filter | Applied by OData here. |
$count | Applied by OData here. |
$orderby | Applied by OData here. |
$expand | Skipped by OData — already turned into Include() calls by ApplyODataExpansions. |
$select | Skipped by OData — handled per-entity if needed by the response shape. |
$top | Skipped by OData — applied manually by .Take(limit). |
$skip | Skipped by OData — applied manually by .Skip(skip). |
$search | Skipped by OData — Formation handles search via _searchService. |
The inverted semantics are a bug farm. A well-intentioned change like “let me make sure $filter is safe, I’ll remove that line” by deleting & ~AllowedQueryOptions.Filter has the opposite of its intended effect: filters stop working because they’re now in the ignore mask. The symptom is that endpoints silently return unfiltered results, which looks like “my query isn’t reaching the database” until you open profiler and see SELECT without the predicate.
The idFilterConsumed branch is the other edge case: if TryApplyEncodedIdFilter consumed the Id filter into a manual Where(s => s.SchemeId == decoded), we add AllowedQueryOptions.Filter to the ignore set so OData doesn’t try to re-apply a filter expression it can’t translate.
ApplyODataExpansions and Polymorphic Collections
Section titled “ApplyODataExpansions and Polymorphic Collections”$expand is translated to EF Core Include() calls by a Formation extension method:
baseQuery = _context.Schemes .AsNoTracking() .ApplyODataExpansions(options);It parses the $expand clause (including nested forms like $expand=Address($expand=Country)), builds a list of dotted navigation paths, and calls query.Include(path) for each.
What it deliberately excludes: Notes, Tags, and ExternalLinks are polymorphic collections — they aren’t EF navigation properties at all. They’re stored in link tables keyed by a parent-entity discriminator and id. Including them via EF would fail at model-configuration time. Instead, they’re loaded after the main query:
// src/services/api/app/app.api/Controllers/SchemesController.cs:101-108await _context.LoadPolymorphicCollectionsAsync( schemes, LinkTableType.Scheme, s => s.SchemeId, (s, notes) => s.Notes = notes, (s, tags) => s.Tags = tags, (s, externalLinks) => s.ExternalLinks = externalLinks);MarketBoundaries is loaded via a similar sidecar because the polygon WKT column is heavy and including it inflates the row count through every nested navigation:
await schemes.LoadLightweightMarketBoundariesAsync(_context.Set<SchemeMarketBoundary>());Encoded-Id Filtering
Section titled “Encoded-Id Filtering”Every entity exposes a string Id in JSON — e.g. "SC1b2Cd" — computed by a deterministic hash of the database integer primary key plus a two-character type prefix:
[NotMapped]public string Id => EncodeIdentifier(DbId);Because Id is [NotMapped], EF Core cannot translate $filter=Id eq 'SC1b2Cd' to SQL — there’s no column to filter on. TryApplyEncodedIdFilter catches this special case: it scans the $filter AST for equality on Id, decodes the encoded value back to the integer primary key, rewrites the query with a straight Where(s => s.SchemeId == decoded), and returns a flag saying the filter was consumed. The calling controller then adds AllowedQueryOptions.Filter to the ignore set so OData doesn’t re-try the unconsumed expression.
This is invisible to callers: GET /Schemes?$filter=Id eq 'SC1b2Cd' works even though the AST it implies wouldn’t round-trip through OData + EF on its own.
Split Queries and Cartesian Explosion
Section titled “Split Queries and Cartesian Explosion”A scheme graph can reach dozens of related rows — Address, BuildingType, Developments, Companies, DevelopmentType, DevelopmentStatus, Market, Country, and so on. With .Include(…) for each, EF’s default single-query strategy produces a Cartesian product: one row per cell of the cross-product, materialised into the result set. 60 schemes × 5 companies × 4 developments quickly becomes tens of thousands of rows per page.
.AsSplitQuery() tells EF to issue one SQL query per navigation instead. The trade-off is more round-trips (N+1 for collections) but much smaller transmitted payloads. Formation uses it universally on list endpoints.
The ordering matters: AsSplitQuery() must come after Skip/Take, otherwise EF applies paging per-query and you paginate each navigation independently, returning only the first page’s children for later pages.
The Write Side
Section titled “The Write Side”EntityWriteControllerBase<TEntity>
Section titled “EntityWriteControllerBase<TEntity>”[#540] extracted the boilerplate that every write controller used to duplicate into a generic base class. A concrete write controller now only has to:
- Declare the route.
- Provide a
FindAsync-style loader forGET /{key}responses. - Project the HTTP body into a typed LiteBus command for each verb.
The base class handles everything else — the Prefer header, encoded-Id decoding, JSON Patch validation, ToErrorResult() conversion, status codes, Location headers.
Key shape (see EntityWriteControllerBase.cs):
[ApiController]public abstract class EntityWriteControllerBase<TEntity> : ControllerBase where TEntity : BaseEntity{ protected readonly FormationDbContext Context; protected readonly ICommandMediator CommandMediator;
protected abstract Task<TEntity?> LoadEntityAsync(int id, CancellationToken ct); protected abstract ICommand<CommandResult<TEntity>> CreateCommand(JsonElement body, bool returnRepresentation); protected abstract ICommand<CommandResult<TEntity>> PatchCommand(int id, JsonElement patchDoc, bool returnRepresentation); protected abstract ICommand<CommandResult> DeleteCommand(int id);
[HttpGet("{key}")] public async Task<ActionResult<TEntity>> Get(string key) { … } [HttpPost] public async Task<IActionResult> Post([FromBody] JsonElement body) { … } [HttpPatch("{key}")] public async Task<IActionResult> Patch(string key, [FromBody] JsonElement patchDoc) { … } [HttpDelete("{key}")] public async Task<IActionResult> Delete(string key) { … }}Inside Post:
// src/services/api/app/app.api/Controllers/EntityWriteControllerBase.cs:52-75public async Task<IActionResult> Post([FromBody] JsonElement body){ var returnRepresentation = WantsRepresentation();
var result = await CommandMediator.SendAsync( CreateCommand(body, returnRepresentation), HttpContext.RequestAborted);
if (result.HasErrors) return result.ToErrorResult();
var entity = result.Data!;
if (returnRepresentation) { ApplyPreferenceHeader(); return CreatedAtAction(nameof(Get), new { key = entity.Id }, entity); }
return CreatedAtAction(nameof(Get), new { key = entity.Id }, null);}The pattern repeats for Patch and Delete. Both check result.HasErrors, call ToErrorResult() on failure, and return the right status code on success.
Put is deliberately excluded from the base class because some controllers take a typed entity (e.g. ReplaceSchemeCommand(id, Scheme, returnRepresentation)) while others take a raw JsonElement. The base provides two protected helpers — PutFromEntity and PutFromJson — and concrete controllers pick one.
Concrete Controllers are Three Lines Each
Section titled “Concrete Controllers are Three Lines Each”A post-#540 write controller is mostly delegation:
[Route("Schemes")]public class SchemesWriteController : EntityWriteControllerBase<Scheme>{ public SchemesWriteController(FormationDbContext context, ICommandMediator commandMediator) : base(context, commandMediator) { }
protected override async Task<Scheme?> LoadEntityAsync(int id, CancellationToken ct) => await Context.Schemes!.FindAsync([id], ct);
protected override ICommand<CommandResult<Scheme>> CreateCommand(JsonElement body, bool returnRepresentation) => new CreateSchemeCommand(body, returnRepresentation);
protected override ICommand<CommandResult<Scheme>> PatchCommand(int id, JsonElement patchDoc, bool returnRepresentation) => new PatchSchemeCommand(id, patchDoc, returnRepresentation);
protected override ICommand<CommandResult> DeleteCommand(int id) => new DeleteSchemeCommand(id);
[HttpPut("{key}")] public async Task<IActionResult> Put(string key, [FromBody] Scheme scheme) => await PutFromEntity(key, scheme, (id, entity, ret) => new ReplaceSchemeCommand(id, entity, ret));}Before #540 each of Post / Patch / Delete / Put was a separate 15-20 line method duplicated across every entity’s write controller. The refactor removed roughly 150 lines per entity without changing behaviour — the only new requirement for new entities is implementing the four abstract methods plus optionally overriding Put.
The Prefer Header and Representation
Section titled “The Prefer Header and Representation”HTTP clients can opt into getting the created/updated entity back in the response body by sending Prefer: return=representation. The base class looks for this header and branches:
// src/services/api/app/app.api/Controllers/EntityWriteControllerBase.cs:125-131protected bool WantsRepresentation() => Request.Headers.TryGetValue("Prefer", out var prefer) && prefer.ToString().Equals("return=representation", StringComparison.OrdinalIgnoreCase);
protected void ApplyPreferenceHeader() => Response.Headers.Append("Preference-Applied", "return=representation");When the client asks for it:
POSTreturns201 CreatedwithLocation: /Schemes/{key}and the entity body.PATCH/PUTreturn200 OKwith the entity body.- The response carries
Preference-Applied: return=representation.
When the client doesn’t ask for it (or sends Prefer: return=minimal, or omits the header):
POSTreturns201 CreatedwithLocation: /Schemes/{key}and no body.PATCH/PUTreturn204 No Content.
This matters because return=representation is expensive — the handler has to re-query the entity with a heavy Include graph to produce the response shape. Clients that are going to navigate to the detail page immediately can skip the round trip; clients that don’t need the body can ask for minimal and save the server work.
JSON Patch vs PUT
Section titled “JSON Patch vs PUT”Patch uses RFC 6902 JSON Patch (application/json-patch+json). The base class enforces that the request body is an array before dispatching:
// src/services/api/app/app.api/Controllers/EntityWriteControllerBase.cs:83-87public async Task<IActionResult> Patch(string key, [FromBody] JsonElement patchDoc){ if (patchDoc.ValueKind != JsonValueKind.Array) return Problem(detail: "JSON Patch must be an array.", statusCode: 400, title: "Bad Request"); …}The command handler then applies the patch operation-by-operation with Formation’s patch-rewriter, which translates nested-object paths to foreign-key paths — e.g. /Address/Id becomes /AddressId. See Patch workflow for the full mechanics.
PUT replaces the entity outright. Most clients prefer PATCH for partial updates; PUT is used by import flows that already hold a fully-assembled entity (e.g. from a spreadsheet) and want to overwrite rather than diff.
Authentication and Authorisation
Section titled “Authentication and Authorisation”There are no [Authorize] attributes on individual controllers in the default case. Authorisation is configured globally via a default filter that requires both authentication and a Formation role claim:
// src/services/api/app/app.api/Program.cs (around line 498)var defaultPolicy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .RequireRole("User", "Admin") .Build();options.Filters.Add(new AuthorizeFilter(defaultPolicy));The effect: any controller action is protected unless explicitly [AllowAnonymous]. Individual controllers override only when they need a stricter policy:
| Policy | Applied to | Semantics |
|---|---|---|
| (default) | All *Controller and *WriteController classes | Authenticated + User or Admin role |
AdminOnly | JobsController, RolesController (subset) | Authenticated + Admin role only |
RequireRole | ExportController, RolesController (subset) | Equivalent to default (legacy explicit form) |
Mock auth runs in development to bypass the real JWT flow. When AzureAd:UseMockAuthentication is true, a MockAuthHandler synthesises a principal with the Admin role (or the role set by AzureAd:MockRole). All controllers see this as a real authenticated user. See services/api.md for the config.
Error Handling — ToErrorResult()
Section titled “Error Handling — ToErrorResult()”Every write controller ends its error path with return result.ToErrorResult(). The extension method maps a CommandResult’s error kind to an RFC 7807 ProblemDetails response, standardised by [#542]:
public static IActionResult ToErrorResult(this CommandResult result){ var errors = result.Errors.ToList();
return result.ErrorType switch { ErrorType.NotFound => new ObjectResult(new ProblemDetails { Status = 404, Title = "Not Found", Detail = errors.FirstOrDefault(), }) { StatusCode = 404 },
ErrorType.Validation => new ObjectResult(new ProblemDetails { Status = 400, Title = "Validation Failed", Extensions = { ["errors"] = errors }, }) { StatusCode = 400 },
ErrorType.Conflict => new ObjectResult(new ProblemDetails { Status = 409, Title = "Conflict", Detail = errors.FirstOrDefault(), Extensions = { ["errors"] = errors }, }) { StatusCode = 409 },
_ => new ObjectResult(new ProblemDetails { Status = 500, Title = "Internal Server Error", Detail = errors.FirstOrDefault(), }) { StatusCode = 500 }, };}The response body is always the RFC 7807 shape:
{ "type": "about:blank", "title": "Validation Failed", "status": 400, "extensions": { "errors": [ "Scheme.Companies[0].PercentageShare: Value must be between 0 and 1", "Scheme.BuildingTypeId: BuildingType not found" ] }}ErrorType | HTTP status | ProblemDetails shape |
|---|---|---|
NotFound | 404 | Title: "Not Found", Detail: first error |
Validation | 400 | Title: "Validation Failed", extensions.errors: [list] |
Conflict | 409 | Title: "Conflict", Detail: first, extensions.errors: [list] |
| (other / none) | 500 | Title: "Internal Server Error", Detail: first error |
Don’t duplicate the switch. Every write controller must route errors through ToErrorResult(). A controller that handcrafts its own error response will drift from the standard shape and break client-side error parsers (errors.ts on the frontend expects this exact shape, including the extensions.errors array on 400/409).
Where Each Kind of Logic Lives
Section titled “Where Each Kind of Logic Lives”A quick reference for where to put code when building a new feature:
| Concern | Home |
|---|---|
| URL routing for reads | *Controller.cs (inherits ODataController) |
| URL routing for writes | *WriteController.cs (inherits EntityWriteControllerBase<T>) |
| Query composition (filter/search/paging) | *Controller.cs + I*SearchService |
| Validation, domain rules, transactions | Handlers/Commands/*/CreateFooCommandHandler.cs |
| Cross-cutting side effects (view rebuild, notifications) | Handlers/Events/*/FooCreatedEventHandler.cs |
| JSON → FK rewrites for nested entities | Services/Patching/IPatchRewriter |
| Error → HTTP response | CommandResultExtensions.ToErrorResult() |
| Authorization policy | Program.cs default policy + [Authorize(Policy = …)] overrides |
Controllers themselves should be thin. Read controllers compose the query pipeline; write controllers dispatch commands. Everything domain-shaped belongs in handlers.
Gotchas
Section titled “Gotchas”-
AllowedQueryOptionsis an ignore list, not an allow list. Flags set are skipped byApplyTo; flags cleared are applied. Deleting& ~AllowedQueryOptions.Filterdisables$filterrather than enforcing it. -
BaseEntity.Idis[NotMapped]. You can’t write EF queries against it. Filters like$filter=Id eq '…'are rewritten byTryApplyEncodedIdFilterbefore reaching OData — don’t add your own.Where(e => e.Id == key)because it won’t translate to SQL. -
Polymorphic collections (
Notes,Tags,ExternalLinks) are not EF navigation properties.$expand=Notesappears to work but skips them silently. They’re loaded byLoadPolymorphicCollectionsAsyncafter the main query. -
AsSplitQuery()must be afterSkip/Take. Put it earlier and paging applies per-navigation, so later pages return partial collections. -
Don’t add
[Authorize]to every controller. The global default policy already requires authentication + role. Per-controller attributes should only tighten (AdminOnly) or explicitly relax ([AllowAnonymous]), never duplicate the default. -
Write controllers must go through
CommandMediator. Doing DB work directly in a write controller bypasses validation, transactions, event publishing, and telemetry. The only DB call allowed in a write controller is theLoadEntityAsyncoverride used to renderGET /{key}responses. -
PUTandPATCHare not interchangeable.PUTreplaces the entity,PATCHapplies a RFC 6902 patch. Clients that send a partial object asPUTwill silently overwrite fields they didn’t include with defaults. -
Prefer: return=representationdoubles the server cost of a write. The handler has to re-query the entity graph to produce the response. Preferreturn=minimal(or omit the header) unless you actually need the body. -
Duplicating
ToErrorResult’s switch is a subtle bug source. The frontend parsesextensions.errorson 400/409 — a hand-rolled error body that returns{ "message": "..." }silently disables the per-field validation UI. -
The global default
RequireRole("User", "Admin")means no-role users get 403, not 401. A freshly provisioned Azure AD user who has authenticated but hasn’t been added to either role group will fail the role check and seeForbidden. The/Rolesendpoint is the only write endpoint explicitly readable to any authenticated user.
See also
Section titled “See also”- CQRS flow with LiteBus — what happens once
CommandMediator.SendAsyncfires - JSON Patch — how nested-object patches are rewritten to FK-level paths
- OData guide — query composition from the frontend side
- EF Core interceptors — audit, soft-delete, and enum-cache invalidation attached to
SaveChanges