Skip to main content

Membership: resources and members

Read this page when you add a resource with members. It covers the member class, the owner, the member list, the managers, the names, and several kinds of resource in one module. The tutorial builds a resource beside Tenancy.

The packages​

note

The packages are DylansTechStack.Membership, .Abstractions, .EntityFramework and .Postgres, and their namespaces are DTS.Membership*. They are part of DTS Pro and are not published on nuget.org. They are 0.x, so their names can still change.

PackageWhat it holds
DylansTechStack.​MembershipThe templates, the member list, the managers' base classes and the generator that writes the list and the managers, the rules, the access manager (MembershipAccessManager<TResource>) and the requirements. It has no database code.
DylansTechStack.​Membership.​AbstractionsMembership's markers for domain classes: [At], on the property that says where a resource hangs, in the namespace DTS.​Membership. No Entity Framework. DTS.​Membership references it.
DylansTechStack.​Membership.​EntityFrameworkThe mapping by convention, with HasMembers() and IsCustomRole() for names of your own, the registrations, the stores the managers load and save through, and WhereIHold for your own queries, on any provider. Its generator writes the join with Tenancy.
DylansTechStack.​Membership.​PostgresThe functions your row access rules call, the lock on the member rows, and a start-up check (On Postgres).
  • Membership needs no dispatcher and no Tenancy. Members are your users unless the rules say otherwise.
  • An aggregate with one owner and no other members needs none of this. Mark the owner [Owner] on the aggregate, and put [Own] or [OrOwn] on the request (The owner of an aggregate).

A document and the people it is shared with​

This example is all you write for a resource whose members are your users. A document is shared with users, and a command shares it. The code compiles as it stands. Only the using lines are left out.

The domain. You write the aggregate, a member class of one line, and the owner property, marked [Owner].

[EntityId<Guid>] public readonly partial record struct UserId;
[EntityId<Guid>] public readonly partial record struct DocumentId;
[EntityId<Guid>] public readonly partial record struct DocumentShareId;

// The member class: the row id, the member id, the role type, and the resource
[Member<DocumentShareId, UserId, NamedRole, Document>]
public sealed partial class DocumentShare;

[AggregateRoot<DocumentId>]
public sealed partial class Document
{
public Document(DocumentId id, UserId owner, DateTimeOffset now) : base(id)
{
OwnerId = owner;
Members.Open(now); // puts the owner on the list, with no end and no role
}

[Owner]
public UserId OwnerId { get; private set; } // the owner holds the permissions marked for the owner

public bool Archived { get; private set; }

public partial IReadOnlyList<DocumentShare> Shares { get; }

// Runs before every change the member manager makes to the shares
partial void OnChangingMembers()
{
if (Archived)
{
throw new RefusalException("documents.archived", RefusalKind.Conflict, "The document is archived.");
}
}
}
  • Members is the member list. The generator writes it over Shares (The generated member list), with the name documents (Names), the codes in Codes (documents.already-member and the others), and the system-work scope filing.
  • The generator also writes DocumentShareManager, the member manager, named after the member class (The managers). Every change to the shares goes through it. The document has no method for them.
  • OnChangingMembers is a hook. The generator declares it on the document, and the member manager calls it before each change. This one refuses every change to the shares of an archived document.

The permissions. Each permission is asked on one document ([On<Document>]). The attribute also says how the permission is held there (The settings of a permission).

[Permissions]
public static class DocumentPermissions
{
[On<Document>(EveryMember = true, Description = "See a document")]
public const string View = "documents.view";

[On<Document>(Description = "Change a document")]
public const string Edit = "documents.edit";

[On<Document>(ManagesMembers = true, NamesOwner = true, Description = "Say who a document is shared with, and hand it on")]
public const string Share = "documents.share";
}
  • The owner holds all three permissions by owning the document. A role can give each of them. Every member holds View.
  • ManagesMembers marks Share as the permission that changes the members. NamesOwner marks it as the permission that hands the document on. The member manager requires them. Without the marks, it makes these changes only for system work.
  • On Postgres, the database also refuses a direct write to the member rows or the owner column from a caller without Share (Who writes the member rows).

The rules. The check in C# and the functions in the database read the same rules. For<Document> reads the name, the codes and the permissions from the type, so the rules only say where the roles come from.

public static class DocumentMembership
{
public static MembershipRules Rules { get; } = MembershipRules.For<Document>(
Roles.Fixed([new("contributor", [DocumentPermissions.View, DocumentPermissions.Edit]), new("onlooker", [DocumentPermissions.View])]));
}
  • Roles.Fixed declares the roles in code, the same for every document. The other choices are Roles.Custom and Roles.External.
  • Who a member is follows from the member id. A UserId over a Guid is compared with the caller's user id.

The wiring.

public sealed class FilingContext(DbContextOptions<FilingContext> options) : DbContext(options)
{
public DbSet<Document> Documents => Set<Document>();

// The conventions map the document and both member tables. The owner is the property marked [Owner].
protected override void ConfigureConventions(ModelConfigurationBuilder configuration)
{
configuration.AddDDDToolkitConventions();
configuration.AddFilingConverters(); // stores your ids with the converters generated for your module
}
}

public static class FilingModule
{
public static IServiceCollection AddFiling(this IServiceCollection services, Action<DbContextOptionsBuilder> database)
{
services.AddDbContext<FilingContext>(database); // your context, on your provider
services.AddDocumentMembership<FilingContext>(DocumentMembership.Rules); // the access manager, the queries and the member manager for documents
services.AddDocumentMemberAccess<IFilingRequest>(); // the check for documents, on your requests
services.RequireExplicitCurrentUser(); // work that begins no user fails
return services.AddScoped<DocumentHandlers>();
}
}
  • The generator writes AddDocumentMembership and AddDocumentMemberAccess, and names them after the resource.
  • AddDDDToolkitConventions() maps Shares as two tables, DocumentShares and DocumentShareRoles, owned by the document. OnModelCreating needs no line for them. Add a migration for the tables.
  • AddDocumentMembership registers MembershipAccessManager<Document> and DocumentShareManager per scope, and the queries on db.Documents (Access in your own queries). The manager loads and saves documents on FilingContext.
  • Without RequireExplicitCurrentUser(), work that begins no user runs as system work, which holds every permission. With it, that work fails.

A request and its handler. [OnResource(DocumentPermissions.Share)] marks the document, and its argument is the permission that the caller holds on the document. [ExpectedVersion] marks the version the caller read. The generator writes MemberAccess.On("documents.share", request.Document, request.ExpectedVersion) into the partial interface.

[AccessRequests]
public partial interface IFilingRequest : IRequireAccess; // what every request of your module implements

public sealed record ShareDocument([OnResource(DocumentPermissions.Share)] DocumentId Document, UserId With, string Role, DateTimeOffset? Until, [ExpectedVersion] long? ExpectedVersion = null) : IFilingRequest;

public sealed class DocumentHandlers(DocumentShareManager shares, ICurrentUser user, TimeProvider clock)
{
// The user who writes a document owns it: the manager adds the new document and saves it
public Task<Document> WriteAsync(CancellationToken cancellationToken)
=> shares.OpenAsync(new Document(DocumentId.Create(), new UserId(user.RequireId()), clock.GetUtcNow()), cancellationToken);

// Runs after the check let the request through: the manager loads the document at the version named,
// checks the role, calls the hook, adds the share from now and saves
public Task<Document> HandleAsync(ShareDocument command, CancellationToken cancellationToken)
=> shares.AddAsync(command.Document, command.With, new NamedRole(command.Role), command.Until, cancellationToken, command.ExpectedVersion);
}
  • The checks must run before the handler. The behavior generated for an [AccessRequests] interface runs them, or your dispatcher calls AccessChecks<IFilingRequest>.RequireAsync(request) (Access requirements). With the behavior, AddDocumentMemberAccess also adds a start-up check. A host without the behavior in its pipeline does not start (When nothing asks the checks).
  • [OnResource(permission)] goes on the id of a resource with members. On any other id it is DDD00081. Its permission must be declared [On<TResource>] for that resource, or it is DDD00093. A request with a marker on each of two resources requires both permissions (Requirements that combine). For a requirement computed from the request's values, implement RequiredAccess instead.
  • The manager uses the request's check. ShareDocument was checked for documents.share on this document, so AddAsync asks nothing again. Without such a request in hand, the manager asks the access manager itself (The managers).
  • The manager checks what the member list cannot know. The role must exist for the resource. With fixed roles, that means a role the rules declare. Where you registered an IMemberDirectory, the new member must be known and active. Where you registered an IMemberRolePolicy, a member must be allowed to hold the role (Who may be a member).
  • Pass the version the caller read. The manager compares it with the document it loads. A change between the check and the load is then a 409 (From the check to the save).
  • Each handler returns the document as the manager saved it.

The generated member list​

The generator writes Members as a private property on the resource. Your constructor opens the list with it. The member manager makes every other change through it.

Show the code the generator writes for the document
Document.Members.g.cs
partial class Document
{
private global::DTS.Membership.MemberList<global::Filing.DocumentShare, global::Filing.DocumentShareId, global::Filing.UserId, global::DTS.Membership.NamedRole> Members
=> new(_shares, OwnerId, Codes);
}

Document.Membership.g.cs, shortened

partial class Document : global::DTS.Membership.IMembershipResource
{
public static global::DTS.Membership.MembershipCodes Codes { get; } = global::DTS.Membership.MembershipCodes.Under("documents");

static string IMembershipResource.MembershipName => "documents";
static MembershipCodes IMembershipResource.MembershipCodes => Codes;
static string? IMembershipResource.MembershipScope => "filing";
}

Document.MemberManagement.g.cs, shortened

partial class Document : global::DTS.Membership.IMembershipResource<global::Filing.DocumentShare, global::Filing.UserId, global::DTS.Membership.NamedRole>
{
partial void OnChangingMembers();
partial void OnMemberAdded(global::Filing.DocumentShare member);
partial void OnMemberRemoved(global::Filing.UserId member);
partial void OnRoleGiven(global::Filing.UserId member, global::DTS.Membership.MemberRole<global::Filing.UserId, global::DTS.Membership.NamedRole> role);
partial void OnRoleTaken(global::Filing.UserId member, global::DTS.Membership.MemberRole<global::Filing.UserId, global::DTS.Membership.NamedRole> role);
partial void OnOwnerNamed(global::DTS.Membership.OwnerNamed<global::Filing.UserId, global::DTS.Membership.NamedRole> named);

// The member manager's way to the list, written as explicit members of the interface: AddMember, GiveRole,
// TakeRole, RemoveMember and NameOwner. Each calls OnChangingMembers, then Members, then the hook of the change.
}
  • The generator writes the list from three things on the resource. Each must be the only one of its kind, so the generator guesses nothing:
    • the get-only partial collection of the member class, typed IReadOnlyList<T>, IReadOnlyCollection<T> or IEnumerable<T>;
    • the [Owner] property, of the member id's type;
    • the static MembershipCodes, which the generator also writes unless you declare one.
  • A new member row gets its id in code, from DocumentShareId.Create(). The generator writes Create() for an [EntityId<Guid>]. An id over any other type declares its own Create().
  • When the generator cannot write the list, DDD00059 says what stands in the way.
  • For another shape, declare the list yourself. Two examples are codes in a class of their own, and a collection called Members. The generator then writes no list and no managers. It still writes the name, the codes and the scope. _shares is the field behind Shares:
private MemberList<DocumentShare, DocumentShareId, UserId, NamedRole> Members
=> new(_shares, OwnerId, DocumentRefusals.Membership);

Names​

  • A resource is named after its class, in the plural. Document becomes documents, CraneBoard becomes crane-boards, and Category becomes categories. Only the last word is made plural, by the plain English rules. A word that ends in s, x, z, ch or sh takes es. A consonant and y become ies. Any other word takes s.
  • The name is written two ways. Codes use dashes (crane-boards.already-member). Functions use underscores (crane_boards_i_see).
  • [Members("People")] sets another name. It gives people.already-member and people_i_see. This name is not made plural. Write it as a class name (People, CraneBoards) or in its final form (crane-boards). It may hold letters and digits, with - or _ between words. A space, any other symbol, or a digit at the start is DDD00091.
  • Renaming the class renames the codes and the functions. Clients then read other codes, and the next access file makes new functions. The export reports once which names went and which came (DDD00090). To keep the old name, write [Members("Documents")].
  • The system-work scope is named after the module, for example filing for Filing (System work).

The managers​

The generator writes two managers for a resource, into the project of the member class, in the global namespace:

ManagerNamed afterWritten when
The member manager, DocumentShareManagerthe member class, with Manager after itthe generator writes the resource's member list
The role manager, BoardRoleManagerthe role class, [CustomRole<BoardRoleId, Board>], with Manager after itthe role class is declared in the project of its resource
  • A handler takes them like any service. The resource's registration, AddDocumentMembership or AddBoardMembershipWithTenancy, adds both per scope.
  • Each method loads, checks, changes and saves, and returns what it saved. A change takes the resource or its id. After the cancellation token comes the version the caller read, for a command that names one.
  • Your code keeps the resource, its constructor and its hooks. The managers keep the rest. The base classes are MemberManager<...> and CustomRoleManager<...> in DTS.Membership.Managers (Managers and access).
  • A type of the same name in your project keeps the manager out. One in a namespace is DDD00106, because the generated manager would hide it. One in the global namespace is your own manager, and the generator writes none.

The member manager​

MethodWhat it doesThe caller needs
OpenAsync(resource)checks that the owner may be a member, then adds the new resource and saves itnothing more than the request's check
AddAsync(resource, member, role, until)adds a member from now, with a role for the same period where one is giventhe permission marked ManagesMembers
GiveRoleAsync(resource, member, role, until)gives a member a role from nowthe permission marked ManagesMembers
TakeRoleAsync(resource, member, role)takes a role from a memberthe permission marked ManagesMembers
RemoveAsync(resource, member)takes a member off, with its rolesthe permission marked ManagesMembers
LeaveAsync(resource)takes the caller off, with its rolesto see the resource, and to be a member that is not the owner
NameOwnerAsync(resource, owner, formerOwner)names another owner (Rules of the member list)the permission marked NamesOwner
ListAsync(resource)the members of a resource the caller sees, the owner first, then by startto see the resource
ListAsync(resources)the same for at most 200 resources, in one statementnothing: a resource the caller does not see is left out

What a change checks, in this order:

  1. The caller's right. The refusals come as the access manager's do: the caller's own refusal when the caller is nobody, not-found, then not-permitted naming the permission. When the request in hand was checked for that permission on that resource, the manager uses the check's answer and sends no statement. A resource whose rules mark no such permission lets only system work make the change.
  2. Who may go on the list. An active member, where you registered an IMemberDirectory. A role that exists for the resource and that a member may hold. With Tenancy, the join answers both (With Tenancy).
  3. The load, at the version named. A resource at another version is a 409.
  4. The change. Your guard, OnChangingMembers(), runs first. Then the member list applies its rules, and the hook of the change runs.
  5. The save, as the caller (The caller's own place has the exception). The method returns the resource as saved, with the events your hooks raised.

The hooks are partial methods that the generator declares on the resource. Implement the ones you need:

HookRuns
OnChangingMembers()before every change, for a guard of yours
OnMemberAdded(member)after a member was added, before the save, with your member row
OnMemberRemoved(memberId)after a member was taken off
OnRoleGiven(memberId, role), OnRoleTaken(memberId, role)after a role was given or taken
OnOwnerNamed(named)after another owner was named, with every change it made

Raise your own domain events from the hooks. The package raises none.

The caller's own place​

A member that takes a role from itself, takes itself off, leaves, or hands on a resource it owns can lose the right that the save needs. Where the database checks every row, the save would then be refused. So the member manager saves such a change as the resource's own work, in the module's scope, for the caller, when both hold:

  • the request in hand is the one whose check read this resource;
  • the unit of work holds no change besides the resource and its members.

Otherwise it saves as the caller, and the database judges every row. With Tenancy, the join begins that work for the caller's seat, so the history names who left (System work).

The role manager​

The role manager changes the roles a customer makes, Roles.Custom(...) (Roles a customer makes):

MethodWhat it does
CreateAsync(role)adds a role your constructor made, and saves it
CreateStarterRolesAsync(make)makes the starter roles the customer does not have yet, each with your function, and saves them
FollowStarterRolesAsync(make)makes what is missing and makes the rest follow their starter roles, in one save. System work alone.
RenameAsync(role, name, description)renames a role and describes it again
SetPermissionsAsync(role, permissions)sets what a role gives
ArchiveAsync(role)archives a role: it gives nothing from then on
ListAsync(), ByIdAsync(roles)the roles there are for the caller, archived ones included
  • Every change requires the permission the rules name in managedWith. System work needs none. Rules without managedWith let only system work change the roles. FollowStarterRolesAsync is system work's alone (Starter roles with Tenancy).
  • Who holds that permission is answered by ICustomRoleAccess<TResource>. With Tenancy, the join registers it, and the caller must hold the permission for the whole tenant. Without Tenancy, register your own, or keep the role changes to system work.
  • The role class gets hooks too: OnRenamed(), OnPermissionsSet(set) and OnArchived(). Each runs only when the role changed.

Rules of the member list​

The member list refuses each of the following before it changes anything. The member manager calls it, so these refusals reach your handler from the manager. MemberList lists every method and refusal.

  • A member is on the list once, for a period. A membership that has ended is over. The member comes back with a new membership, without the old roles. While a membership runs or is still to start, adding the member again is refused (documents.already-member), whatever start the new membership has. The member manager passes the current time to decide this.
  • A period ends after it starts. A period that ends at or before its start is refused (documents.invalid-period).
  • A member holds each role once, for a period of its own. A role counts only while the membership counts.
  • A resource has one owner. Members.Open(now) puts the owner on the list, with no end and no role. The owner cannot be removed and cannot get a role (documents.owner-protected) until somebody else is named owner.
  • NameOwnerAsync(resource, owner, formerOwner) names a new owner. The new owner is a member from now on, with no end and no role. The call adds the member if needed, drops the member's roles, lifts its end and moves its start to now. Handing the resource on again restores none of this. Naming the current owner is refused (documents.already-owner).
  • You choose what happens to the former owner. FormerOwner.StaysAs(role) keeps the former owner as a member in that role. FormerOwner.Leaves takes the former owner off the list. There is no default.
  • OwnerNamed lists every change, and the OnOwnerNamed hook receives it. One of the changes is EndLifted, for a new owner who should still leave when the old membership would have ended.
  • The members live inside your aggregate, under its single version, so If-Match covers them. The package raises no events. Raise your own events from the hooks.
  • Refusals carry your codes. Document.Codes.Refuse(MembershipRefusals.NotFound) gives documents.not-found. With renames one code, and WithMemberArgument("Staff") renames the argument that carries the member (Refusals).
  • The registration brings the texts, in English and Dutch, for your failure localizer. A text of your own for the same code is used first.
  • Invariants catch code that changed the collection another way. Write invariants that call OneMembershipPerMember() and OwnerStays().
  • For a screen of members, the member manager's ListAsync returns the owner first, then the other members by start (tutorial step 6).

Several kinds of resource​

Each resource has its own member class, rules, registrations, tables, codes, functions, access manager (MembershipAccessManager<Folder> beside MembershipAccessManager<Document>) and member manager (FolderMemberManager beside DocumentShareManager). Two resources share none of these. The module keeps one request interface, which gets one check per resource. Adding a second resource changes none of the existing calls:

[Member<FolderMemberId, StaffCode, NamedRole, Folder>]
public sealed partial class FolderMember;

services.AddDocumentMembership<FilingContext>(DocumentMembership.Rules);
services.AddDocumentMemberAccess<IFilingRequest>();
services.AddFolderMembership<FilingContext>(FolderMembership.Rules);
services.AddFolderMemberAccess<IFilingRequest>();
  • Each resource has one member class. A second member class for the same resource is DDD00045. A member class that names something other than one of your aggregate roots, such as a child entity, is DDD00050. Both are reported on the member class. In a domain project without the registrations, both mistakes are DDD00060.

  • For table and column names of your own, call HasMembers in OnModelCreating. A hand-written call wins over the convention, for that resource:

    modelBuilder.Entity<Folder>().HasMembers(folder => folder.Staff,
    new MemberTableNames("FolderStaff", "FolderStaffRoles", MemberColumn: "StaffCode"));
  • IsCustomRole() does the same for a role class that a context maps in a table layout of its own (Roles a customer makes). A members collection that the context ignores stays unmapped.

  • A composite key works. Both tables use the resource's full key, with its [KeyPart] parts under their own names. With HasMembers, call HasKey before HasMembers. A key set after HasMembers is refused where the mapping is read.

  • In GraphQL, name each resource's MemberRole yourself. MemberRole<TMemberId, TRoleId> has no [DisplayName], because two resources close it twice. HotChocolate names it after its type arguments, such as MemberRoleOfStaffCodeAndNamedRole. A type class with descriptor.Name("FolderMemberRole") gives it a name of yours (Display names of the packages' classes). Its method AppliesAt(moment) is no field, because MemberRole is an entity.