Advanced

This page covers keyless entity support, the internal DI architecture via IOtterApiRegistry, and a complete reference table of known limitations and caveats.

Keyless Entities

If an entity is marked with [Keyless] (e.g. a database view), OtterApi registers it as read-only: only GET is available. Attempting POST, PUT, PATCH, or DELETE returns 405 Method Not Allowed with code KEYLESS_ENTITY.

Keyless entity (database view)
[Keyless]
public class ProductSummaryView
{
    public string  Name         { get; set; }
    public decimal Price        { get; set; }
    public string  CategoryName { get; set; }
}

// Registration
options.Entity<ProductSummaryView>("product-summary");

// Only GET is available:
// GET /api/product-summary
// GET /api/product-summary/count

DI Architecture — IOtterApiRegistry

At startup, AddOtterApi builds an OtterApiRegistry singleton and registers it in the DI container under the IOtterApiRegistry interface:

DI registration (internal)
services.AddSingleton<IOtterApiRegistry>(registry);

IOtterApiRegistry interface

Interface definition
public interface IOtterApiRegistry
{
    IReadOnlyList<OtterApiEntity>  Entities              { get; }
    OtterApiOptions               Options               { get; }
    JsonSerializerOptions         SerializationOptions  { get; }
    JsonSerializerOptions         DeserializationOptions{ get; }
    JsonSerializerOptions         PatchOptions          { get; }
    OtterApiEntity? FindEntityForPath(PathString requestPath, out PathString remainder);
}

FindEntityForPath is the O(1) route resolver — a case-insensitive dictionary built once at startup and consulted on every request (at most two probes: exact-match for collection routes, parent-segment match for by-id / sub-routes).

Why depend on the interface

💡
The concrete OtterApiRegistry class remains public for scenarios where you need to instantiate it directly (e.g. integration tests that construct OtterApiRestController by hand).

Limitations & Caveats

A complete reference of known constraints and edge cases:

LimitationDetails
Single [Key] Composite primary keys are not supported.
MaxPageSize default MaxPageSize defaults to 1000. Set to 0 to disable (use with caution on large tables).
EF Core DbSet required The entity must be registered as DbSet<T> in the provided DbContext. If it is not, InvalidOperationException is thrown at startup.
Filterable property types Supported: primitives, string, Guid, DateTime, DateTimeOffset, enum, and nullable variants. Objects and collections cannot be filter fields.
include depth Only navigation properties declared directly on the entity are loaded. Nested includes (deeper than one level) are not supported. Unknown property names in include are silently ignored.
Hooks and DI Hooks are registered once at startup. Scoped dependencies must be resolved manually through the provided DbContext or a service scope factory.
Keyless entities GET only (list + filter + count). POST, PUT, PATCH, and DELETE return 405.
operator=or is global (flat syntax) The flat operator=or switches join logic for all filters in the request. Use grouped syntax for mixed AND/OR logic.
Validation OtterApi validates Data Annotations ([Required], [MaxLength], etc.) using IObjectModelValidator. Invalid requests return 400 with model state.
Enum serialization Enums are serialized as integers in all responses. Deserialized case-insensitively as both strings ("Pending") and integers (0) in request bodies.
PUT / DELETE / PATCH without Id Return 400 Bad Request. The Id must always be part of the URL path.
Trailing slash A trailing slash (e.g. /api/products/) is treated as a collection request, identical to /api/products.
.Allow() and HTTP methods Requests for a method not included in AllowedOperations return 405 Method Not Allowed.
Middleware order UseOtterApi() must be placed after UseAuthentication() / UseAuthorization() and before UseEndpoints() / MapControllers().
WithQueryFilter — EF-translatable only Predicates must be expressible in SQL. Arbitrary C# logic that cannot be converted to a query will throw at runtime.
WithQueryFilter — static only Compiled once at startup. Cannot reference request-scoped data. Use .WithScopedQueryFilter() for dynamic filtering.
WithScopedQueryFilter — EF-translatable only The returned predicate must still be EF-translatable. The factory itself can use any C# logic.
WithCustomRoute — reserved slugs The slugs count and pagedresult are reserved and throw InvalidOperationException at startup if used.
WithCustomRoute — unique slugs Each slug must be unique per entity. Duplicate slugs throw InvalidOperationException at startup.
WithCustomRoute — GET only Custom routes are read-only GET endpoints. POST, PUT, PATCH, and DELETE are not supported.
Target framework .NET 8.0 is required.
BeforeSave / AfterSave and transactions Pre-save and post-save handlers are not wrapped in a database transaction. SaveChangesAsync is called between the two lists. If an AfterSave handler throws, the database changes are already committed and will not be rolled back. For atomic side-effects, manage the transaction manually inside a BeforeSave handler or implement the Outbox pattern.
Optimistic concurrency DbUpdateConcurrencyException is caught as a generic DbUpdateException and returns 422 with code DB_UPDATE_ERROR. It is not mapped to 409 Conflict. Handle DbUpdateConcurrencyException in a hook or wrapping middleware if needed.
PATCH and custom JsonSerializerOptions The PATCH document is parsed with a fresh default JsonSerializerOptions (JsonSerializerDefaults.Web) regardless of custom options. Custom naming policies and converters are applied when individual field values are deserialized from the patch node. All other verbs (POST, PUT) fully respect custom options.
IOtterApiRegistry Registered as AddSingleton<IOtterApiRegistry>. When instantiating OtterApiSwaggerDocumentFilter or OtterApiRestController manually (e.g. in tests), pass a concrete OtterApiRegistry instance — it implements IOtterApiRegistry.