IOwnable — Typed User Ownership Marker
IOwnable is the framework’s marker interface for aggregates owned by a
specific user. It sits next to IMultiTenant,
ISoftDeletable, and IHasMergeTombstone — a
declarative tag that EF Core conventions, authorization handlers, and GDPR
cascades all key off of.
namespace Granit.Domain;
public interface IOwnable{ Guid OwnerId { get; }}Two design points distinguish it from IMultiTenant:
- Get-only on the interface. Ownership is a domain decision, not an
ambient dimension. The aggregate exposes the value; mutation happens through
a named behavioural method (
TransferOwnership), never through the interface. - No automatic injection. Unlike
TenantId,OwnerIdis not set by the persistence interceptor. The aggregate factory MUST require an explicitGuidand fail loudly when it cannot be resolved. The reason is laid out in ADR-060: the caller is not always the owner (imports, transfers, service accounts, GDPR reassignment).
Why get-only
Section titled “Why get-only”Compare to IMultiTenant:
public interface IMultiTenant{ string? TenantId { get; set; } // settable — interceptor injects}
public interface IOwnable{ Guid OwnerId { get; } // get-only — factory enforces}The settable shape on IMultiTenant exists because TenantId is ambient
state: the persistence interceptor reads ICurrentTenant.Id and injects it
into every IMultiTenant entity at Added time. The caller never thinks
about tenants; the framework guarantees the value.
OwnerId cannot work that way. An admin importing a CSV creates documents
owned by the imported users, not by the admin. A GDPR reassignment flow
changes ownership of orphaned records without an active session. A service
account creating an entity on behalf of a human cannot use its own machine
identity as the owner. The interceptor has no way to decide which of those is
the right answer — only the command handler does.
So the contract is:
flowchart LR
Endpoint["Endpoint / Command handler"] -->|"resolves caller's UserGuid<br/>or supplied ownerId"| Factory["Aggregate.Create(ownerId, ...)"]
Factory -->|"throws on Guid.Empty"| Aggregate["new Aggregate { OwnerId = ownerId }"]
Aggregate -->|"private set"| EF["EF Core column"]
Interceptor["AuditedEntityInterceptor<br/>(Added)"] -.->|"does NOT touch OwnerId"| Aggregate
Tenant["MultiTenantInterceptor<br/>(Added)"] -->|"injects TenantId"| Aggregate
style Factory fill:#0ea5e9,color:#fff
style Interceptor stroke-dasharray: 5 5
The dashed line is the key contrast: the interceptor reads IMultiTenant and
writes TenantId; it reads IOwnable for nothing. EF Core still
materialises the value through the entity’s private set, which keeps DDD
encapsulation intact and lets tests build aggregates via the factory rather
than property initialisers.
Automatic index convention
Section titled “Automatic index convention”ApplyGranitConventions in Granit.Persistence.EntityFrameworkCore
detects every IOwnable entity and emits a composite index:
| Entity also implements | Index | Column order |
|---|---|---|
| IMultiTenant | (TenantId, OwnerId) | Tenant first — aligns with the multi-tenant query filter |
| (only IOwnable) | (OwnerId) | Single-column |
The convention skips entities without their own table (TPH-derived types, keyless query types) and de-duplicates: if you declare an index over the same property set explicitly (with a custom name, say), the convention sees it and backs off.
// In a *Configuration.cs — only needed if you want a custom index name,// since the convention emits the same shape automatically.builder.HasIndex(x => new { x.TenantId, x.OwnerId }) .HasDatabaseName("IX_Documents_TenantId_OwnerId");Pattern of use
Section titled “Pattern of use”A typical IOwnable aggregate combines four pieces: the marker, an explicit
factory, a behavioural transfer method, and an *OwnerChangedEvent raised
through the domain-event pipeline.
public sealed class Document : AggregateRoot, IMultiTenant, IOwnable, IEmitEntityLifecycleEvents{ public string Name { get; private set; } public string? TenantId { get; set; } public Guid OwnerId { get; private set; }
private Document() { } // EF
public static Document Create(Guid ownerId, string name, Guid folderId) { if (ownerId == Guid.Empty) { throw new ArgumentException("Owner id cannot be empty.", nameof(ownerId)); } // ... other invariants
var doc = new Document { Id = Guid.NewGuid(), Name = name, OwnerId = ownerId, /* ... */ }; doc.AddDomainEvent(new DocumentCreatedEvent(doc.Id, doc.TenantId, doc.FolderId, doc.OwnerId, doc.Name)); return doc; }
public void TransferOwnership(Guid newOwnerId) { ThrowIfNotActive(nameof(TransferOwnership)); if (newOwnerId == Guid.Empty) { throw new ArgumentException("New owner id cannot be empty.", nameof(newOwnerId)); } if (newOwnerId == OwnerId) { return; // idempotent }
Guid oldOwnerId = OwnerId; OwnerId = newOwnerId; AddDomainEvent(new DocumentOwnerChangedEvent(Id, TenantId, oldOwnerId, newOwnerId)); }}Four invariants the factory enforces, that the interface cannot:
Guid.Emptyrejected at construction. A nullableUserGuidupstream surfaces as a parse-time throw, not a silent zero in the database.- State-aware mutation.
ThrowIfNotActive(orThrowIfTrashed,ThrowIfTenantRootfor folders) gates transfer on aggregate state — you cannot transfer ownership of a trashed document. - Idempotent self-transfer. Re-transferring to the same owner is a no-op — no event emitted, no audit noise.
- Domain event emitted. Every transfer publishes
<Aggregate>OwnerChangedEventcarrying old and new ids, which the audit pipeline and per-module reactors consume.
TransferOwnership
Section titled “TransferOwnership”The convention is a public instance method named TransferOwnership on
the aggregate, taking a single Guid newOwnerId. It pairs with a
*OwnerChangedEvent record in the same namespace as the aggregate:
public sealed record DocumentOwnerChangedEvent( Guid DocumentId, string? TenantId, Guid OldOwnerId, Guid NewOwnerId) : IDomainEvent;Endpoints expose it as PUT /{resource}/{id}/owner, gated by a dedicated
<Resource>.TransferOwnership permission distinct from the regular write
permission. That separation matters: a user with Documents.Manage may
edit metadata without being able to give away ownership.
Forbidden roots
Section titled “Forbidden roots”If the aggregate has a notion of a “system anchor” instance — a root entity
that exists per tenant and that the framework itself depends on — that
instance MUST be excluded from TransferOwnership at the domain layer, not
at the endpoint layer. Folder does this with ThrowIfTenantRoot. The
reason: GDPR cascades and admin scripts call the service method directly;
the guard belongs where the invariant lives.
Authorization
Section titled “Authorization”Granit.Authorization ships an OwnedRequirement and a resource-based
handler so policies can express “user X may perform action Y on resource R
because they own it”.
public sealed class OwnedRequirement : IAuthorizationRequirement;The handler is fail-silent:
internal sealed class OwnedByCurrentUserHandler(ICurrentUserService currentUser) : AuthorizationHandler<OwnedRequirement, IOwnable>{ protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, OwnedRequirement requirement, IOwnable resource) { if (currentUser.UserGuid is Guid id && id == resource.OwnerId) { context.Succeed(requirement); } return Task.CompletedTask; }}Three points worth calling out:
- The handler never calls
context.Fail(). WhenUserGuidisnull(non-Guidsub, machine actor, unauthenticated) or when ownership doesn’t match, the requirement simply stays unsatisfied. A stacked handler — permission-based, role-based, share-based — can still grant access. See ADR-060 for the rationale. - Resource is
IOwnable. The handler matches on the marker, so any aggregate that implementsIOwnableworks without a per-type registration. - Composition with permission policies. Typical pattern: combine an ownership grant with an explicit “I can manage what I own” permission, letting share-based grants from other modules (share ACL) override or extend the ownership default.
Composing in a policy
Section titled “Composing in a policy”services.AddAuthorization(options =>{ options.AddPolicy("Documents.Edit", policy => { policy.RequireAuthenticatedUser(); // Either: user has Documents.Edit permission, OR they own the resource policy.AddRequirements( new PermissionRequirement("Documents.Documents.Edit"), new OwnedRequirement()); });});Because both requirements are added to the same policy, ASP.NET evaluates them with OR semantics: either handler succeeding is enough. To enforce AND, register two policies and apply both.
Calling the resource-based check
Section titled “Calling the resource-based check”Resource-based checks must go through IAuthorizationService.AuthorizeAsync
with the resource instance — the requirement cannot fire on the principal
alone, since it depends on the resource’s OwnerId:
var auth = await authorizationService.AuthorizeAsync( user: HttpContext.User, resource: document, requirements: [new OwnedRequirement()]);
if (!auth.Succeeded) { return Results.Forbid(); }Sibling markers
Section titled “Sibling markers”IOwnable is one of a small family of declarative domain markers in
Granit.Domain / framework code. Each one drives a distinct slice of EF
plumbing through ApplyGranitConventions:
| Marker | Plane | Convention applied |
|---|---|---|
| IMultiTenant | Tenant isolation | Interceptor injection + MultiTenant query filter |
| ISoftDeletable | Lifecycle | Interceptor sets IsDeleted on delete + SoftDelete query filter |
| IOwnable | Ownership | (TenantId, OwnerId) or (OwnerId) index — no filter, no injection |
| IHasMergeTombstone | Merge lineage | MergedIntoId / MergedAt columns + MergeTombstone query filter |
The conventional column type for ownership identifiers is Guid — see
ADR-060 for the IDP
compatibility matrix and the rationale.
GDPR cascade
Section titled “GDPR cascade”IOwnable is the join key for the framework’s personal-data deletion
fan-out: a module that owns user-keyed aggregates subscribes to
PersonalDataDeletionRequestedEto and processes every aggregate where
OwnerId == request.UserId. The pattern, the handler shape, and the
“system anchor” exclusion rule are documented in
Personal Data Deletion — Cascade cleanup for owned entities.
See also
Section titled “See also”- ADR-060 — OIDC sub as Guid —
UserGuidcontract and IDP matrix - Multi-tenancy —
IMultiTenant, the ambient-injection counterpart - EntityMerge —
IHasMergeTombstone, sibling marker - Query filters — why ownership is not a filter
- Authorization — permissions, policies, requirement stacking
- Personal Data Deletion — GDPR Art. 17 cascade for
IOwnablemodules - Documents — first business module to adopt the pattern