Skip to main content

Writing your own supporting domain

Your core domain is what your application is for. You write it with the building blocks. Next to it are domains that many applications need and that make no application different: subscriptions, tenants, audit. A supporting domain is such a domain, written once as a package and used by many applications. The toolkit ships its own under DTS.*, Tenancy and Membership, and you can write one the same way.

A supporting domain is written with the building blocks, like your own modules. When an application references it, it becomes a module of that application. The way the application extends it makes it a package instead of a copy:

  • The application owns the ids. It declares them with [EntityId<T>] in its contracts project, over any value type. The package never forces a Guid on anyone.
  • The application owns the classes. It declares each aggregate and entity of the package as a class of its own, and adds properties, behaviour, rules and entities to it.
  • The package owns the rules. Every rule the package states runs for the application's class, before the class's own rules. The application can add rules. It cannot leave one out by accident.
  • The application owns the database. The package brings no migrations and no DbContext. The application's context and migrations stay in the module that owns them.

The design principles list the rules that Tenancy and Membership follow, each with its reason. Read them before you design a package of your own.

The example on this page is a package for subscriptions and their invoices, Acme.Subscriptions, and a shop that uses it.

A parent and its template​

The package declares each aggregate as an abstract generic parent, marked [AggregateRootBase], with the id as its first type parameter. You write it like any other aggregate: rules nested in it, a CheckInvariants() method, read-only collections.

[AggregateRootBase]
public abstract partial class SubscriptionAggregate<TSubscriptionId>
where TSubscriptionId : IEntityId, IEquatable<TSubscriptionId>
{
protected SubscriptionAggregate(TSubscriptionId id, string plan) : base(id) => Plan = plan;

public string Plan { get; private set; } = "";

public sealed class PlanIsRequired : IInvariant<SubscriptionAggregate<TSubscriptionId>>
{
public string Code => "subscription.plan";

public InvariantFailure? Check(SubscriptionAggregate<TSubscriptionId> entity)
=> entity.Plan.Length > 0 ? null : "A subscription is on a plan.";
}
}

Next to the parent goes the template. The template is the attribute that the application puts on its class. It names the parent, and its type arguments fill the parent's first type parameters, the id first:

[AggregateRootTemplate(typeof(SubscriptionAggregate<>))]
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class SubscriptionAttribute<TSubscriptionId> : Attribute;

A child entity works the same way, with [EntityBase] on the parent and [EntityTemplate] on the attribute.

What the application writes​

The application declares its ids, and declares its own class with the template. It adds what only the application knows:

[EntityId<Guid>]
public readonly partial record struct SubscriptionId; // in its contracts project

[Subscription<SubscriptionId>]
public sealed partial class ShopSubscription
{
public ShopSubscription(SubscriptionId id, string plan, bool isTrial) : base(id, plan) => IsTrial = isTrial;

public bool IsTrial { get; private set; }

public sealed class TrialsAreOnTheFreePlan : IInvariant<ShopSubscription> { ... }
}

The generator makes the class derive from the parent, closed over the application's id. It makes the parent derive from the toolkit's AggregateRoot<TId>. Nobody writes a base class by hand.

In the diagram, purple marks the application's class:

Show the code: what the generator writes

SubscriptionAggregate.g.cs and ShopSubscription.g.cs, shortened:

partial class SubscriptionAggregate<TSubscriptionId> : global::DTS.DDD.BaseTypes.AggregateRoot<TSubscriptionId>
{
protected void CollectBaseInvariantViolations(
ref global::System.Collections.Generic.List<global::DTS.DDD.Invariants.InvariantViolation>? violations,
ref global::DTS.DDD.Exceptions.InvariantViolationException? seamFailure,
global::System.Type entityType)
{
foreach (var invariant in __invariants)
{
// ...
}

// ...then its CheckInvariants() method
}
}

partial class ShopSubscription : global::Acme.Subscriptions.SubscriptionAggregate<global::Shop.Contracts.SubscriptionId>
{
private void CollectInvariantViolations(
ref global::System.Collections.Generic.List<global::DTS.DDD.Invariants.InvariantViolation>? violations,
out global::DTS.DDD.Exceptions.InvariantViolationException? seamFailure)
{
seamFailure = null;
CollectBaseInvariantViolations(ref violations, ref seamFailure, typeof(global::Shop.Billing.ShopSubscription));

foreach (var invariant in __invariants)
{
// ...
}

// ...then its own CheckInvariants() method
}
}

Invariants of the parent and the class​

Every rule that the parent states runs for every class derived from it, and so does the parent's CheckInvariants(). The parent's rules run first, then the class's rules. Both come back in one list from GetInvariantViolations(), or as one exception from EnsureInvariants() and from the save. Every violation names the application's class, ShopSubscription, because that is the object that is wrong. The toolkit checks the parent's child entities the same way, so a rule on the package's invoice line runs when the application's invoice is checked.

The parent also gets the four public invariant methods, which run only the parent's rules. A class declared with the template replaces them with its own methods. They exist for a class that derives from the parent by hand. Without them, such a class would check none of the package's rules.

A rule of the application can be about its class, about the parent, or about an interface the parent implements. All of them are nested in the application's class:

[Subscription<SubscriptionId>]
public sealed partial class ShopSubscription
{
public sealed class TrialsAreOnTheFreePlan : IInvariant<ShopSubscription> { ... }

public sealed class PlanIsKnown : IInvariant<SubscriptionAggregate<SubscriptionId>> { ... }
}

Parents that need more than an id​

An invoice needs more than its own id. It needs the id of the subscription it bills, and the class of its lines, which the application declares. A [TemplateArgument] fills such a type parameter from the one class declared with the template it names. By default it takes that class's id. With Take = TemplateArgumentKind.Type it takes the class itself:

[AggregateRootTemplate(typeof(InvoiceAggregate<,,,>))]
[TemplateArgument(1, typeof(SubscriptionAttribute<>))]
[TemplateArgument(2, typeof(InvoiceLineAttribute<>), Take = TemplateArgumentKind.Type)]
[TemplateArgument(3, typeof(InvoiceLineAttribute<>))]
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class InvoiceAttribute<TInvoiceId> : Attribute;

The application declares each class once, and the generator fills in the rest:

[InvoiceLine<InvoiceLineId>]
public sealed partial class ShopInvoiceLine : IInvoiceLineFactory<ShopInvoiceLine, InvoiceLineId> { ... }

[Invoice<InvoiceId>]
public sealed partial class ShopInvoice
{
public ShopInvoice(InvoiceId id, SubscriptionId subscriptionId) : base(id, subscriptionId) { }
}

ShopInvoice derives from InvoiceAggregate<InvoiceId, SubscriptionId, ShopInvoiceLine, InvoiceLineId>, with the subscription's id taken from ShopSubscription. The generator looks for the class a template takes from in the application's project first. When that project declares none, it looks in the projects the project references. So a billing module can take the subscription's id from the module that declares the subscription.

Creating the application's entities​

A parent that holds the application's child entities cannot call their constructor, because it does not know their class. A static abstract factory on an interface that the child implements creates them. The parent constrains its type parameter to that interface:

public interface IInvoiceLineFactory<TSelf, TLineId>
where TSelf : IInvoiceLineFactory<TSelf, TLineId>
{
static abstract TSelf Create(TLineId id, decimal amount);
}

[AggregateRootBase]
public abstract partial class InvoiceAggregate<TInvoiceId, TSubscriptionId, TLine, TLineId>
where TInvoiceId : IEntityId, IEquatable<TInvoiceId>
where TSubscriptionId : IEntityId, IEquatable<TSubscriptionId>
where TLine : InvoiceLineEntity<TLineId>, IInvoiceLineFactory<TLine, TLineId>
where TLineId : IEntityId, IEquatable<TLineId>
{
public partial IReadOnlyList<TLine> Lines { get; }

public TLine Charge(TLineId id, decimal amount)
{
var line = TLine.Create(id, amount);
_lines.Add(line);
return line;
}
}

An application class that does not implement the factory is reported with DDD00048 on the class that takes it, instead of as a compile error in the generated code. A new() constraint cannot replace the factory, because the parameterless constructor that the generator writes is never public.

Ids the package creates​

The invoice line above receives its id. A package that creates a new id itself, as Tenancy creates the id of every seat it adds, asks the id type for it: TLineId.Create(), the static member of ICreatableEntityId<TSelf>. It does this in code, before the save. The generator implements Create() for every [EntityId<Guid>], with a time-ordered id. An application whose id is over a long declares its own Create(). The package states this in two places, in its code and on its template:

// The code that creates the ids requires id types with a Create().
public abstract class SubscriptionUseCases<TSubscription, TSubscriptionId>
where TSubscription : SubscriptionAggregate<TSubscriptionId>
where TSubscriptionId : struct, ICreatableEntityId<TSubscriptionId>, IEquatable<TSubscriptionId>
{
public static TSubscriptionId NewId() => TSubscriptionId.Create();
}

// The template states that the package creates the ids of its classes.
[AggregateRootTemplate(typeof(SubscriptionAggregate<>), CreatesIds = true)]
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class SubscriptionAttribute<TSubscriptionId> : Attribute;

With CreatesIds:

  • An application class declared over an id without a Create() is DDD00067, on the class, with a code fix that adds Create() to the id.
  • The registrations and the classes of [TemplateFacade] also require Create() in their constraints. They do not report the class again, and the compiler reports no error in generated code.

Without CreatesIds, the toolkit cannot tell the application which class to fix. Then a registration reports the problem with DDD00050, and a class of [TemplateFacade] with DDD00065. Leave CreatesIds unset for a template whose ids the application creates itself. Its ids then need no Create().

Storing the classes with Entity Framework​

Nothing changes. Entity Framework maps the application's classes, and the parent's properties and collections become part of each class. A child entity declared with an [EntityTemplate] is an owned type like any other child entity. Entity Framework never maps the parent. The parent is an open generic, and Entity Framework only sees the classes closed over the application's ids. [KeyPart] properties on a parent come first in the key, before the class's own key parts.

A row access rule is written for the application's class, and reads the parent's properties and collections like the class's own. A rule about the parent itself is refused with DDD00040, because the parent has no table.

A marker mapped by a convention​

Some facts about an application's class concern a package: which subscription a delivery belongs to, which tenant a project belongs to. The application states such a fact on its domain class, with an attribute that the package ships. This page calls such an attribute a marker. A domain project references no Entity Framework, so the marker maps nothing by itself. The package maps it in its Entity Framework package, with a convention. [ModelConvention] on the marker names that convention. A context then maps every marked class, and writes no line per class.

The example marks the property of the application's Delivery that names its subscription. The package's convention gives that property an index.

Acme.Subscriptions.Abstractions, the marker:

[ModelConvention("Acme.Subscriptions.EntityFramework.OfSubscriptionConvention, Acme.Subscriptions.EntityFramework")]
[AttributeUsage(AttributeTargets.Property, Inherited = false)]
public sealed class OfSubscriptionAttribute : Attribute;

Acme.Subscriptions.EntityFramework, the convention:

internal sealed class OfSubscriptionConvention : IModelFinalizingConvention
{
public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
{
foreach (var entityType in modelBuilder.Metadata.GetEntityTypes().ToList())
{
var marked = entityType.ClrType.GetProperties().FirstOrDefault(each => each.IsDefined(typeof(OfSubscriptionAttribute)));
if (marked is not null && entityType.FindProperty(marked.Name) is { } property)
{
entityType.Builder.HasIndex([property]); // a delivery is found by its subscription
}
}
}
}

The application, a domain class and the context that maps it:

// Shop.Deliveries.Domain: references Acme.Subscriptions.Abstractions, and no Entity Framework
[AggregateRoot<DeliveryId>]
public sealed partial class Delivery
{
[OfSubscription] public SubscriptionId SubscriptionId { get; private set; }
}

// Shop.Deliveries.Infrastructure: references Acme.Subscriptions.EntityFramework
public sealed class DeliveriesContext(DbContextOptions<DeliveriesContext> options) : DbContext(options)
{
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
=> configurationBuilder.AddDDDToolkitConventions(); // runs OfSubscriptionConvention, because Delivery is marked
}

In the diagram, purple marks the application's class with the marker, and green the convention that the context runs for it:

How the convention runs:

  • AddDDDToolkitConventions() runs it. The call adds the toolkit's MarkerConvention. When the model is finished, after OnModelCreating, MarkerConvention reads the attributes on every class the model maps and on the class's properties. It runs each convention that a marker names once per model, in the order the model first names it. The convention finds the marked classes itself.
  • Every mapped class counts. That is a class the context names, a class that Entity Framework reaches only through a navigation of another class, and a class the model owns, also where two owners share it. A model without a marked class is not touched.
  • What the context configures by hand wins. The convention runs after OnModelCreating, so it sees the context's own mapping, and can leave a class that the context mapped itself as it is. Tenancy's convention leaves a class that the context keeps to a tenant with ScopeToTenant. Membership's leaves a members collection that the context maps with HasMembers.
  • The order. MarkerConvention runs before the conventions of the version and the key parts, so what a package maps is keyed as the rest of the model is. It also runs before the check of the indexes that say what they refuse with (RefusesAs), so an index that a package adds is checked.
  • A generic marker names the convention on its definition. A template attribute such as Membership's [Member<TId, TMemberId, TRoleId, TResource>] carries [ModelConvention] once. Every class declared with it, over any type arguments, runs the convention.
  • No registration. The conventions follow from the classes a model maps, and from nothing registered in the container. So dotnet ef and the Supabase export build the model the application runs with.
  • A part the model needs. A convention whose mapping needs a part of the context, such as an interceptor, states it on the model with the overload for conventions: modelBuilder.Metadata.RequireContextPart(...). A context without the part is then refused at its first save (Code the package writes for the application).

A missing convention refuses the model. The marker names the convention by its assembly-qualified name, as text, because the marker's assembly does not reference Entity Framework. A module whose infrastructure project lacks the package's Entity Framework assembly cannot load that name. The toolkit then refuses the model while it is built, at the context's first use, in dotnet ef and in the Supabase export. The message names the class, the marker and the assembly to reference:

System.InvalidOperationException: 'Delivery.SubscriptionId' is marked [OfSubscription], which
Acme.Subscriptions.EntityFramework maps, and the application does not have it. Reference
Acme.Subscriptions.EntityFramework from the project of the context, or remove the marker.

A skipped convention would leave the marker mapping nothing, and no message would say so. For Tenancy's [OfTenant], a skipped convention would give a module whose reads see the rows of every tenant. So the toolkit refuses the model before any row is read.

A context that leaves AddDDDToolkitConventions() out runs no convention at all, and the toolkit cannot see that from the marker. A package whose mapping must be there checks it where it can. Tenancy's save check refuses a context whose model has a class marked [OfTenant] that is not kept to a tenant, at its first save.

Rules for the convention:

  • It implements Entity Framework's IModelFinalizingConvention.
  • It has a public constructor that takes nothing. The class itself may be internal to its package, because the toolkit makes it by its name. A class without such a constructor refuses the model, with a message that says so.
  • The marker names it as "Namespace.Convention, Assembly", on the marker's class. A marker on a property is read on the property. A marker on a class is read on the class.

Tenancy's [OfTenant] and Membership's [Member<...>] and [CustomRole<...>] are mapped this way (Keeping tenants apart, Resources and members).

Markers in a package of their own​

A domain project of another module often needs a package's marker and nothing else of the package. A delivery names its subscription, and a project names its tenant. Put the markers in a small package of their own, <Package>.Abstractions:

  • It references DTS.DDD.Abstractions alone, and no Entity Framework.
  • It declares no classes, ids or value objects, so no generator runs on it.
  • It uses the package's own namespace, so an application names the markers beside the package's other types.
  • The package's main package references it, so a project with the package has the markers as well.

A module's domain project then references the markers' package and the ids it needs. Its infrastructure project references the package's Entity Framework package, which holds the convention.

A generator that checks where a marker goes ships in the main package, next to the code that reads the marker. So a project that references only the markers' package gets no diagnostic at the declaration. The project that uses the package's rules gets it.

The toolkit's markers are split this way:

Markers' packageNamespaceMarkerRead by
DylansTechStack.​Tenancy.​AbstractionsDTS.​Tenancy[OfTenant]: the tenant an aggregate root belongs toOfTenantConvention, in DylansTechStack.​Tenancy.​EntityFramework
DylansTechStack.​Membership.​AbstractionsDTS.​Membership[At]: where an aggregate root hangsthe rules of a resource with members. Membership's generator, in DylansTechStack.​Membership, reports DDD00097

[Owner] stays in the core, in DTS.DDD.Abstractions, because the core's checks of the owner read it.

Row level security from a package​

A package can ship its own row level security (RLS), for tables that are not the application's aggregates, and for SQL that names the schema and the id types that the application chose. It does this with a row access contribution. That is a class that writes functions, policies and statements from the application's model when the export runs.

The package declares itself a contributor with one assembly attribute. From then on, a reference to the package is the application's consent. The Supabase export of every application that references the package writes the package's SQL. The application writes no line and no class for it. A package that needs its policies in the database to work leaves the application nothing to choose.

Where the SQL depends on something only the application knows, the package takes the value as a constructor parameter of its class. For each parameter, it names the attribute that the application marks the value with. The package ships that attribute too, next to the type of the value.

In the diagram, purple marks the build, which creates the class, and green marks the class it writes:

The build creates the class in the project that runs the export, before any host exists, from the static property or field that the application marks:

  • A parameter with a default value may stay unmarked. So give a default wherever the package can work without the value, as Tenancy does for its catalogue.
  • A parameter without a default that nothing marks is a warning, DDD00054, and the package's SQL is not written.
  • Every = true hands the parameter every member marked with the attribute, as Tenancy collects the permissions of every module.
  • A generic attribute closes a generic contribution once for every member it marks, as Membership is created once for each resource.
Show the code: a package that takes the application's plans
// In the package's Postgres project
[assembly: RowAccessContribution(typeof(SubscriptionRowAccess))]

public class SubscriptionRowAccess(
[FromApplication(typeof(SubscriptionPlansAttribute))] IReadOnlyList<SubscriptionPlan> plans,
[FromApplication(typeof(GraceDaysAttribute))] int graceDays = 14) : IRowAccessContribution
{
public string Owner => "subscriptions";

public RowAccessContributionResult? Contribute(DbContext context, RowAccessExport export) { ... }
}

// In the package's domain project, which the application's own projects reference
[ApplicationMark]
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class SubscriptionPlansAttribute : Attribute;

[ApplicationMark]
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class GraceDaysAttribute : Attribute;

// In the application
public static class ShopPlans
{
[SubscriptionPlans]
public static IReadOnlyList<SubscriptionPlan> All { get; } = [new("free"), new("pro")];
}

Rules for the class and the marks:

  • The class is public, is not abstract, and implements IRowAccessContribution. It has one public constructor. Every parameter of that constructor states where its value comes from, or the constructor has no parameters. A class that the build cannot create is DDD00071 in every application that references the package, and the diagnostic names the package.
  • Put the marker attributes where the application declares the values: in the package's domain project, and not in its Postgres project. Then the project that declares the values needs no reference to Postgres.
  • The build searches the exporting project, and every project it references that references the attribute's assembly. It skips the assembly that declares the attribute, so a package can mark its own example values without the build taking them for the application's.
  • Put [ApplicationMark] on each marker attribute. The exporting project sees only the public members of a library. An application that marks an internal member would get the default or nothing, without a diagnostic. With [ApplicationMark] on the marker, the toolkit's analyzer reports that member where it is declared, DDD00070.
  • Test it as the toolkit tests Tenancy and Membership: with a host that references the package and marks nothing, one that marks everything, and one that marks a value twice.

The access files name the package's class and assembly above the SQL it writes, without a version. The class that the build writes into the exporting project hands the export the package's class through IPackageRowAccessContribution. A new version of the package that writes the same SQL leaves every application's access files unchanged. A version that writes other SQL makes the next export write a new access file.

A package is an assembly that declares no module. An application's own module that writes its own SQL may declare its class the same way, with [assembly: RowAccessContribution]. Because that assembly declares a module, the declaration is only an offer. The host lists it with [assembly: UseRowAccessContribution], because that SQL belongs to the application. Until the host lists it, the build warns with DDD00069.

Functions for the application's rules. The package declares the questions that the application's rules may ask in an [AccessFunctions(Owner = "subscriptions")] class, each an extension method on IRowUser, with this IRowUser user as its first parameter. So a question cannot be called where there is no rule. A question over the application's ids may carry [TemplateRegistration(Shorthand = true)] and [TemplateType]s, for a wrapper without type arguments in every project of a module, as Tenancy's questions do. The functions that the package contributes answer these questions. The export finds them by their logical names, such as subscriptions/active_plans, in the default schema of the context that maps the package's tables. A package that keeps the access to a resource of the application, as Membership keeps a resource's members, lets the application ask by the resource's id instead. Its function states which set it returns (Answers on the ContributedFunction). A rule asks user.Sees<TId>(), so neither side names the function (A resource's access, asked by its id).

Data for other modules. The package also gives other modules its data as functions, and not as its tables. Each function returns rows under its own column names, and the package ships the mapping that reads them into a module's context. A module's model then names what the package offers, and nothing about how the package stores it. So the application can name the package's tables and columns as it likes, and the policies on those tables still decide what each caller gets (Tenancy's read functions).

Limit what a module can read this way to what a rule needs. A read model of ids, permissions, periods and statuses lets a module ask "is this subscription active" inside its own query. It gives the module nothing to show: no plan name, no subscriber name. Names are a separate question, which a module asks the package by id. A directory method such as PlansByIdAsync(ids) returns the names of the plans in a module's result, to callers who may work with those plans, and leaves out an id that the caller may not ask about. A module's results then carry ids, and the code that shows them asks the package for the names. Ship a check next to the mapping. The check reports what a module's model maps of the package beyond the read model, such as its own type on one of the package's tables, or a property added to a row. A test of the application then checks every module against it (What a module reads of Tenancy, and its names).

What the contribution writes follows from what the package owns:

  • Policies on its own tables. The package lists these tables in ExclusiveTables, so no rule of the application adds a policy next to them.
  • A default read policy on a table of the application's class. The application often decides who reads such a table, for example who reads the subscriptions. Then the package marks its read policy a default, with the rows its own work reads and the limit it keeps every caller to. The application replaces the default with a read rule on its class. The export then writes the application's rule within that limit, next to the rows the package's work reads. Tenancy's seats work this way.
  • Restrictive policies on the application's tables that depend on the package, such as a table limited to one subscription. A restrictive policy is never merged with the policies of the rules, and no rule can widen it.
  • Statements: the triggers that check what no policy can see, such as a rule about rows other than the row written, checked at commit.

An application can leave a contribution out, or never mark its data. The contribution then writes nothing, and the build at most warns. So the package also ships a start-up check that its policies are in the database and were written from what the application runs with. For example, a function written from the catalogue of subscription plans that the application is configured with returns the same plans as that catalogue. The package's registration for Postgres registers that check with services.AddStartupCheck(...), in the database stage. A host that runs its start-up checks then gets the check without naming it. Tenancy's Postgres package does all of this.

Rows that belong to a customer. A package can keep rows that a customer creates, in a class of the application. The package knows no customer. Which customer a row belongs to is a column that the application adds, checked by a rule that the application writes. The package's functions run as their owner and see the rows of every customer, so they cannot check "of the same customer". The package can still make the database enforce what the application declared:

  • A restrictive policy that queries the application's table as the caller, EXISTS (SELECT 1 FROM <the application's table> ... ), gets its answer under the application's own rules on that table. So a row that refers to a row the caller cannot see is not written.
  • A rule that the package's own operations apply to every caller, such as a state that no row may be left in, is a trigger written from the rules. The trigger applies to whoever writes the row.

Membership's roles a customer makes use both.

A registration closed over your classes​

A package usually also ships a registration: a method that adds its tables to the application's model, or its services to the container. The package cannot name the application's classes, so the method is generic over them. An application with three classes and three ids would name all six in every call:

modelBuilder.AddSubscriptions<ShopSubscription, SubscriptionId, ShopInvoice, InvoiceId, ShopInvoiceLine, InvoiceLineId>();

To avoid this:

  • Mark the method [TemplateRegistration].
  • Mark each type parameter that the application fills with [TemplateType], and name the template whose class fills it.
  • Name the type that declares the method in an assembly attribute, [TemplateRegistrations]. Then the generator in the application finds the method without searching every type of every reference.
[assembly: TemplateRegistrations(typeof(Acme.Subscriptions.SubscriptionModelBuilderExtensions))]

public static class SubscriptionModelBuilderExtensions
{
[TemplateRegistration]
public static ModelBuilder AddSubscriptions<
[TemplateType(typeof(SubscriptionAttribute<>), Take = TemplateArgumentKind.Type)] TSubscription,
[TemplateType(typeof(SubscriptionAttribute<>))] TSubscriptionId,
[TemplateType(typeof(InvoiceAttribute<>), Take = TemplateArgumentKind.Type)] TInvoice,
[TemplateType(typeof(InvoiceAttribute<>))] TInvoiceId,
[TemplateType(typeof(InvoiceLineAttribute<>), Take = TemplateArgumentKind.Type)] TLine,
[TemplateType(typeof(InvoiceLineAttribute<>))] TLineId>(
this ModelBuilder modelBuilder, string schema = "sales")
where TSubscription : SubscriptionAggregate<TSubscriptionId>
where TInvoice : InvoiceAggregate<TInvoiceId, TSubscriptionId, TLine, TLineId>
where TLine : InvoiceLineEntity<TLineId>, IInvoiceLineFactory<TLine, TLineId>
// and the three ids, constrained as the parents constrain them
{
modelBuilder.Entity<TSubscription>().ToTable("Subscriptions", schema);
modelBuilder.Entity<TInvoice>().ToTable("Invoices", schema);
return modelBuilder;
}
}

The application calls the method without its classes:

protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.AddSubscriptions();

In the diagram, purple marks the method that the generator writes into the application:

Show the code: what the generator writes

SubscriptionModelBuilderExtensions.AddSubscriptions.Registration.g.cs, shortened:

namespace Acme.Subscriptions;

internal static partial class GeneratedSubscriptionModelBuilderExtensions
{
public static global::Microsoft.EntityFrameworkCore.ModelBuilder AddSubscriptions(
this global::Microsoft.EntityFrameworkCore.ModelBuilder modelBuilder, string schema = "sales")
=> global::Acme.Subscriptions.SubscriptionModelBuilderExtensions.AddSubscriptions<
global::Shop.Billing.ShopSubscription, global::Shop.Contracts.SubscriptionId,
global::Shop.Billing.ShopInvoice, global::Shop.Contracts.InvoiceId,
global::Shop.Billing.ShopInvoiceLine, global::Shop.Contracts.InvoiceLineId>(modelBuilder, schema);
}
  • Each [TemplateType] is filled like a [TemplateArgument], from the one class declared with the template: in the project first, and then in the projects it references. By default it takes that class's id. With Take = TemplateArgumentKind.Type it takes the class itself. A template with more type arguments than the id hands those over too (A template with more than one type argument). A template that may be declared more than once gets a registration per class (A template declared more than once).
  • A type parameter without [TemplateType] stays open, with its constraints. So the application still names what only it can choose: services.AddSubscriptions<BillingContext>(options => ...).
  • The parameters are the method's own, default values included, and the generated method passes them on.
  • The generated method is internal, in a class named Generated plus the declaring type's name, in the declaring type's namespace. The using that the application already has for the package is enough for a call. Two projects that both declare the classes never see each other's generated method.
  • The generic method keeps working. The generated method calls it, and an application can call it too.

A project gets the registration when it declares a class with at least one of the method's templates. A project that declares none, such as the package itself or a module that only uses the ids, gets nothing and no diagnostic. The exception is a project of the same module as the projects that declare the classes, declared with DDD_Module or [assembly: Module] (In a module split by layer). A project that declares some of the classes and misses one gets DDD00049, which names the missing one, and a code fix declares it. That is why the registrations belong in a package of their own, as Tenancy keeps them in DTS.Tenancy.EntityFramework. A domain module that declares one of the classes and does not reference the storage package is never asked for the rest.

A template with more than one type argument​

Not every type a parent needs comes from another class. A comment is written by someone, and the application decides what identifies that someone: its own StaffId, or a Guid. Such a type is a further type argument of the template, after the id. It fills the parent's next type parameter:

[AggregateRootBase]
public abstract partial class CommentAggregate<TCommentId, TAuthorId>
where TCommentId : IEntityId, IEquatable<TCommentId>
where TAuthorId : struct, IEquatable<TAuthorId>
{
protected CommentAggregate(TCommentId id, TAuthorId writtenBy, string text) : base(id)
{
WrittenBy = writtenBy;
Text = text;
}

public TAuthorId WrittenBy { get; private set; }

public string Text { get; private set; } = "";
}

[AggregateRootTemplate(typeof(CommentAggregate<,>), AllowSeveral = true)]
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class CommentAttribute<TCommentId, TAuthorId> : Attribute;
[Comment<InvoiceCommentId, StaffId>]
public sealed partial class ShopInvoiceComment
{
public ShopInvoiceComment(InvoiceCommentId id, StaffId writtenBy, string text) : base(id, writtenBy, text) { }
}

Only the first type argument must be an entity id. The others are whatever the application writes there, checked against the parent's constraints (DDD00053).

A registration that takes the class also takes those type arguments, with Argument. It counts from zero, like the attribute's own type arguments, so 0 is the id and you do not need to state it:

[assembly: TemplateRegistrations(typeof(Acme.Subscriptions.CommentModelBuilderExtensions))]

public static class CommentModelBuilderExtensions
{
[TemplateRegistration]
public static ModelBuilder AddComments<
[TemplateType(typeof(CommentAttribute<,>), Take = TemplateArgumentKind.Type)] TComment,
[TemplateType(typeof(CommentAttribute<,>))] TCommentId,
[TemplateType(typeof(CommentAttribute<,>), Argument = 1)] TAuthorId>(
this ModelBuilder modelBuilder, string table)
where TComment : CommentAggregate<TCommentId, TAuthorId>
where TCommentId : IEntityId, IEquatable<TCommentId>
where TAuthorId : struct, IEquatable<TAuthorId>
{
modelBuilder.Entity<TComment>().ToTable(table);
return modelBuilder;
}
}
modelBuilder.AddComments("InvoiceComments"); // AddComments<ShopInvoiceComment, InvoiceCommentId, StaffId>

The registration must take every one of them. ShopInvoiceComment is a CommentAggregate<InvoiceCommentId, StaffId> and nothing else. A generated method that left TAuthorId for the caller to write would not compile, because the class would then have to be a comment by whatever type the caller wrote. A type argument that does not meet a constraint of the method is reported on the class with DDD00050, as an id is.

A template declared more than once​

Most templates stand for something an application has one of: its tenant, its subscription. A second class declared with such a template is a mistake, and DDD00045 reports it. A template for comments is different. The shop has comments on subscriptions and comments on invoices, each a class with its own table. The package allows this on its template attribute with AllowSeveral = true, as CommentAttribute does above.

An application with one such class calls the registration by the method's name, as always. An application with several classes gets one registration per class, named after the class:

[Comment<SubscriptionCommentId, StaffId>]
public sealed partial class ShopSubscriptionComment { ... }

[Comment<InvoiceCommentId, StaffId>]
public sealed partial class ShopInvoiceComment { ... }

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.AddCommentsForShopSubscriptionComment("SubscriptionComments");
modelBuilder.AddCommentsForShopInvoiceComment("InvoiceComments");
}

In the diagram, purple marks the generated class, with one registration per class:

Show the code: what the generator writes

CommentModelBuilderExtensions.AddComments.Registration.g.cs, shortened:

namespace Acme.Subscriptions;

internal static partial class GeneratedCommentModelBuilderExtensions
{
public static global::Microsoft.EntityFrameworkCore.ModelBuilder AddCommentsForShopSubscriptionComment(
this global::Microsoft.EntityFrameworkCore.ModelBuilder modelBuilder, string table)
=> global::Acme.Subscriptions.CommentModelBuilderExtensions.AddComments<
global::Shop.Billing.ShopSubscriptionComment, global::Shop.Contracts.SubscriptionCommentId,
global::Shop.Contracts.StaffId>(modelBuilder, table);

public static global::Microsoft.EntityFrameworkCore.ModelBuilder AddCommentsForShopInvoiceComment(
this global::Microsoft.EntityFrameworkCore.ModelBuilder modelBuilder, string table)
=> global::Acme.Subscriptions.CommentModelBuilderExtensions.AddComments<
global::Shop.Billing.ShopInvoiceComment, global::Shop.Contracts.InvoiceCommentId,
global::Shop.Contracts.StaffId>(modelBuilder, table);
}
  • The name says which class a registration is closed over, so the generator picks nothing for the application without telling it. With several classes, the method's own name is not generated. A project that declares its second class calls both registrations by their class names from then on. A package that wants calls to stay the same names its registration itself.
  • Every type parameter that names the template is filled from the same class. The class, its id and its later type arguments belong together.
  • Only one template per method may have several classes. A method that takes classes from two templates that each have several classes cannot tell which class goes with which. Two classes with the same name cannot be told apart by their registrations. Both cases are DDD00045, and the diagnostic says what to do. The generic method still works for them, with the type arguments written out.
  • A parent is closed over one class. A [TemplateArgument] that takes a type from such a template still needs exactly one class. So leave AllowSeveral off a template that another template takes from.
  • Write the package for several classes. The registration is called once per class in one application. Key every service, option and name by the class or its id, never by the author's id alone. Take table names as parameters instead of fixing them. Membership is written that way. It has a member class per kind of resource, and everything it registers is requested by the resource.

A module split by layer gets these registrations the same way. Its infrastructure project gets one registration for each class that the module's projects declare.

Naming a registration​

A registration has its method's name, and {Method}For{Class} once a project declares a second class. A package can choose the name instead, with Name on [TemplateRegistration]. The name contains a type parameter of the method in braces, and the generator puts the name of the type that fills that parameter there. The registration then has that name with one class and with several classes, so a second class changes no existing call.

A registration is often best named after what the class is about: the discounts on an invoice, the members of a document. The template states this. A template attribute may have more type arguments than its parent takes. Those type arguments are not the parent's, and a registration takes them by position. With IdOfArgument = true, the registration takes the id of the entity or aggregate root that one of them names. So the application writes that class once, where it declares its own class, and writes neither the class nor its id again:

[EntityBase]
public abstract partial class DiscountEntity<TDiscountId>
where TDiscountId : IEntityId, IEquatable<TDiscountId>
{
protected DiscountEntity(TDiscountId id, decimal percent) : base(id) => Percent = percent;

public decimal Percent { get; private set; }
}

// TOwner is the class the discounts are given on. The parent takes the id alone.
[EntityTemplate(typeof(DiscountEntity<>), AllowSeveral = true)]
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class DiscountAttribute<TDiscountId, TOwner> : Attribute;

public static class DiscountModelBuilderExtensions
{
[TemplateRegistration(Name = "Add{TOwner}Discounts")]
public static ModelBuilder AddDiscounts<
[TemplateType(typeof(DiscountAttribute<,>), Take = TemplateArgumentKind.Type)] TDiscount,
[TemplateType(typeof(DiscountAttribute<,>))] TDiscountId,
[TemplateType(typeof(DiscountAttribute<,>), Argument = 1)] TOwner,
[TemplateType(typeof(DiscountAttribute<,>), Argument = 1, IdOfArgument = true)] TOwnerId>(
this ModelBuilder modelBuilder)
where TDiscount : DiscountEntity<TDiscountId>
where TDiscountId : IEntityId, IEquatable<TDiscountId>
where TOwner : AggregateRoot<TOwnerId>
where TOwnerId : IEntityId, IEquatable<TOwnerId>
{
modelBuilder.Entity<TDiscount>().ToTable(typeof(TOwner).Name + "Discounts");
return modelBuilder;
}
}
[Discount<InvoiceDiscountId, ShopInvoice>]
public sealed partial class ShopInvoiceDiscount
{
public ShopInvoiceDiscount(InvoiceDiscountId id, decimal percent) : base(id, percent) { }
}

modelBuilder.AddShopInvoiceDiscounts(); // AddDiscounts<ShopInvoiceDiscount, InvoiceDiscountId, ShopInvoice, InvoiceId>
  • The name is the type's own name, as its declaration writes it, without its namespace: ShopInvoice. Only a type parameter with [TemplateType] can be named, because the generated method is closed over it. A type without a name of its own, such as an array, is reported on the class (DDD00050).
  • Each registration needs its own name. Two classes of discounts on the one ShopInvoice would both be AddShopInvoiceDiscounts, and a call could not tell them apart. DDD00045 names both. Classes with one name in two namespaces are no problem, as long as the types they are named after differ.
  • The id comes from the way the class is declared: the id of [AggregateRoot<InvoiceId>], of [Entity<T>] or of a template, in the project or in a project it references. It can also be the id generated next to a class declared over a raw value, [AggregateRoot<Guid>]. A type declared in none of these ways has no id to take, and is reported on the class that names it (DDD00050).
  • Only the registration's constraints check the extra type argument. Constrain the attribute's own type parameter where the compiler should catch a mistake: where TOwner : class. An entity or aggregate root of the application's own project is checked against the registration's constraints by what the generator will make of it. So a child entity named where TOwner : AggregateRoot<TOwnerId> is required is reported on the class that names it (DDD00050), and not inside the registration.

Membership is written this way. [Member<DocumentShareId, UserId, NamedRole, Document>] names the document whose members the class holds, and the application calls services.AddDocumentMembership<TContext>(rules).

In a module split by layer​

An application that splits a module into projects by layer declares the classes in its domain project. That project references the package and not its storage. The application calls the registration from its infrastructure project, which holds the context and declares none of the classes. The infrastructure project still gets the registration, because it declares the same module. It declares the module with DDD_Module, which the module's folder can set for every project of the module (A module named by its folder), or with [assembly: Module] as below. The registration is closed over the classes that the module's other projects declare, and only those.

In the diagram, purple marks the project that gets the generated registration. Grey marks a project of another module, which gets nothing:

Show the code: the two projects of the module
// Shop.Billing.Domain: references Acme.Subscriptions, and no Entity Framework
[assembly: Module("Billing")]

[Subscription<SubscriptionId>]
public partial class ShopSubscription;

// Shop.Billing.Infrastructure: references the domain project and Acme.Subscriptions.EntityFramework
[assembly: Module("Billing")]

public sealed class BillingContext(DbContextOptions<BillingContext> options) : DbContext(options)
{
// Generated into this project, closed over the classes the domain project declares
protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.AddSubscriptions();
}

Only the lowest such project gets the generated registration. A project of the module above it, such as an API project that references the infrastructure project to compose the module, also declares none of the classes. It sees the same registration through that reference, and gets nothing. The registration is already in the project below it, and the API project calls that project's public registration.

A project of another module that references the domain project gets nothing and no diagnostic. So does a project that declares no module. The generator never looks for a class in the package, which declares no module. When the module's projects declare a class with one of the method's templates and none with another, the infrastructure project gets DDD00049, which names the missing template. The diagnostic is on the project's [assembly: Module] attribute, or at its project file where DDD_Module declares the module. The missing class belongs next to the others, in the domain project.

A class closed over your classes​

A package's managers, and the records they take and return, are generic over the application's classes. The application would write their type arguments wherever it names them, in every project that does. To avoid this:

  • mark each type parameter of the package's class [TemplateType], as for a registration;
  • name the class in an assembly attribute, [assembly: TemplateFacade].

The project that declares the application's classes then gets a class of its own, named as the package's class is without its type parameters. It derives from the package's class, closed over the application's classes. What the application gets depends on the package's class:

The package's classThe application getsUsed as
a class that is not abstract, with public constructorsa sealed class with the same public constructorsa service the container makes, such as Tenancy's SeatManager
a record that is not sealeda sealed record with the same public constructorsa record the application makes and hands to the package, such as Tenancy's TenantToProvision
an abstract class with a protected constructoran abstract class with a private constructora name for the nested types and the static members of the package's class
an interfacea global using alias, named without the leading I, in every project that sees the classesan interface the storage package implements, such as Tenancy's ITenancyStore<.​.​.​>, which the application names TenancyStore

In the diagram, purple marks the class that the generator writes into the application:

A service the container makes​

A manager holds the package's commands and reads for one aggregate. The package declares it as a class that is not abstract, with a public constructor that takes the services it needs:

[assembly: TemplateFacade(typeof(Acme.Subscriptions.SubscriptionManager<,>))]

public class SubscriptionManager<
[TemplateType(typeof(SubscriptionAttribute<>), Take = TemplateArgumentKind.Type)] TSubscription,
[TemplateType(typeof(SubscriptionAttribute<>))] TSubscriptionId>
where TSubscription : SubscriptionAggregate<TSubscriptionId>
where TSubscriptionId : IEntityId, IEquatable<TSubscriptionId>
{
public SubscriptionManager(ISubscriptionStore<TSubscription, TSubscriptionId> store, TimeProvider clock) { ... }

public Task<TSubscription> RenewAsync(TSubscriptionId subscription, CancellationToken cancellationToken) { ... }
}

The project that declares ShopSubscription gets a sealed SubscriptionManager with the same constructor. The application's code takes it like any service, and every member it calls is the package's own:

public sealed class RenewHandler(SubscriptionManager subscriptions)
{
public Task<ShopSubscription> HandleAsync(Renew command, CancellationToken cancellationToken)
=> subscriptions.RenewAsync(command.Subscription, cancellationToken);
}

The package's registration registers the application's class. Mark the registration's type parameter [TemplateFacade(typeof(...))]. The generated registration fills it with the class the project gets, or with the one a project of its module below it has. Where there is none, it fills it with the package's class itself, closed over the classes:

[TemplateRegistration]
public static IServiceCollection AddSubscriptions<
[TemplateType(typeof(SubscriptionAttribute<>), Take = TemplateArgumentKind.Type)] TSubscription,
[TemplateType(typeof(SubscriptionAttribute<>))] TSubscriptionId,
[TemplateFacade(typeof(SubscriptionManager<,>))] TSubscriptionManager,
TContext>(this IServiceCollection services)
where TSubscription : SubscriptionAggregate<TSubscriptionId>
where TSubscriptionId : IEntityId, IEquatable<TSubscriptionId>
where TSubscriptionManager : SubscriptionManager<TSubscription, TSubscriptionId>
where TContext : DbContext
{
services.TryAddScoped<TSubscriptionManager>();
return services;
}

The application calls services.AddSubscriptions<BillingContext>(), and the container makes the application's SubscriptionManager.

Show the code: what the generator writes

SubscriptionManager.TemplateFacade.g.cs, shortened:

/// <summary>
/// SubscriptionManager, closed over the classes of the module Billing: ShopSubscription and SubscriptionId.
/// The container makes it, and the package's registration registers it: your code takes it, and every member is the
/// package's own.
/// Named as the package's class is; [assembly: TemplateFacadeName("SubscriptionManager", "...")] in this project names it otherwise.
/// </summary>
public sealed class SubscriptionManager : global::Acme.Subscriptions.SubscriptionManager<
global::Shop.Billing.ShopSubscription, global::Shop.Contracts.SubscriptionId>
{
/// <summary>Made by the container, with what the package's class asks for.</summary>
public SubscriptionManager(
global::Acme.Subscriptions.ISubscriptionStore<global::Shop.Billing.ShopSubscription, global::Shop.Contracts.SubscriptionId> store,
global::System.TimeProvider clock)
: base(store, clock)
{
}
}

A record the application makes​

A record that the application makes and hands to a manager is a record of the package that is not sealed. The application gets a sealed record with each public constructor of the package's record, its default values included. The application writes new TenantToProvision(...) without type arguments, and Tenancy's TenantManager.ProvisionAsync takes it as the package's record.

A name for nested types​

An abstract class with a protected constructor gets an abstract class with a private constructor, which nothing makes. It is a name only. The package nests types in it, such as records and small services, and the application names them through it:

[assembly: TemplateFacade(typeof(Acme.Subscriptions.SubscriptionUseCases<,,,,,>))]

public abstract partial class SubscriptionUseCases<
[TemplateType(typeof(SubscriptionAttribute<>), Take = TemplateArgumentKind.Type)] TSubscription,
[TemplateType(typeof(SubscriptionAttribute<>))] TSubscriptionId,
[TemplateType(typeof(InvoiceAttribute<>), Take = TemplateArgumentKind.Type)] TInvoice,
[TemplateType(typeof(InvoiceAttribute<>))] TInvoiceId,
[TemplateType(typeof(InvoiceLineAttribute<>), Take = TemplateArgumentKind.Type)] TLine,
[TemplateType(typeof(InvoiceLineAttribute<>))] TLineId>
where TSubscription : SubscriptionAggregate<TSubscriptionId>
where TInvoice : InvoiceAggregate<TInvoiceId, TSubscriptionId, TLine, TLineId>
where TLine : InvoiceLineEntity<TLineId>, IInvoiceLineFactory<TLine, TLineId>
// and the three ids, constrained as the parents constrain them
{
// For the class the application gets, which derives from this one. Nothing creates either class.
protected SubscriptionUseCases()
{
}

public sealed record InvoiceDue(TInvoiceId Invoice, TSubscriptionId Subscription, decimal Amount);

public sealed class Dunning
{
public InvoiceDue Due(TInvoice invoice) => new(invoice.Id, invoice.SubscriptionId, invoice.Lines.Sum(line => line.Amount));
}
}

The project that declares the classes gets a class named SubscriptionUseCases. That project and every project above it name the nested types through this class, without type arguments:

public sealed class Reminders(SubscriptionUseCases.Dunning dunning)
{
public SubscriptionUseCases.InvoiceDue DueFor(ShopInvoice invoice) => dunning.Due(invoice);
}
Show the code: what the generator writes

SubscriptionUseCases.TemplateFacade.g.cs

/// <summary>
/// SubscriptionUseCases, closed over the classes of the module Billing: ShopSubscription, SubscriptionId,
/// ShopInvoice, InvoiceId, ShopInvoiceLine and InvoiceLineId.
/// Every type nested in it, and every static member, is named through this class and is the package's own: the type
/// its registration added, the method it declares.
/// The class is only that name. Nothing makes one, and nothing derives from it.
/// Named as the package's class is; [assembly: TemplateFacadeName("SubscriptionUseCases", "...")] in this project names it otherwise.
/// </summary>
public abstract class SubscriptionUseCases : global::Acme.Subscriptions.SubscriptionUseCases<
global::Shop.Billing.ShopSubscription, global::Shop.Contracts.SubscriptionId,
global::Shop.Billing.ShopInvoice, global::Shop.Contracts.InvoiceId,
global::Shop.Billing.ShopInvoiceLine, global::Shop.Contracts.InvoiceLineId>
{
private SubscriptionUseCases()
{
}
}
  • A name, and no second type. C# finds a type nested in a class through every class derived from it. So SubscriptionUseCases.Dunning is the package's own Dunning, closed over the application's classes. It is the type that the package's registration adds to the container, with the package's documentation. The same holds for the types nested in a manager: Tenancy's SeatManager.SeatOverview is the package's own record.
  • Calls as well as names. C# finds a static member through a derived class, as it finds a nested type. So put members that are generic over the classes, and that code calls, on the same class. The application calls them without type arguments: public static IDisposable BeginRun() => DunningRuns.Begin<TSubscriptionId>(); on SubscriptionUseCases<...> is SubscriptionUseCases.BeginRun(). The compiler binds a call, and the compiler sees the output of every generator. So a call also works in the project the class is generated into, where another generator would not see the class.

An interface the storage implements​

A store is an interface of the package that a storage package implements and registers. The container hands out the package's interface, closed over the application's classes, so no class of the application's can stand for it. The application gets a global using alias instead, named as the interface is without the leading I:

[assembly: TemplateFacade(typeof(Acme.Subscriptions.ISubscriptionStore<,>))]

SubscriptionStore.TemplateAlias.g.cs

// ISubscriptionStore, closed over the classes of the module Billing: Subscription and SubscriptionId.
// Named as the package's interface is, without its I; [assembly: TemplateFacadeName("SubscriptionStore", "...")] in this project names it otherwise.
global using SubscriptionStore = global::Acme.Subscriptions.ISubscriptionStore<global::Shop.Billing.Domain.Subscription, global::Shop.Billing.Contracts.SubscriptionId>;
  • An alias works only in its own project. So the project that declares the classes gets the alias, and so does every project above it that sees the classes the generator wrote there. Each alias is closed over the types those classes are closed over.
  • A project above gets the alias only when the toolkit's generator runs in it. A package reference brings the generator.
  • A name given with [assembly: TemplateFacadeName] in the project that declares the classes holds in every project above.
  • A project that sees two modules' classes would get two aliases of one name. It gets neither, and DDD00075 says so at its project file.

Rules for the generated classes​

  • Written once, where the classes are. A project gets the class when it declares a class with one of the type's templates. Each type parameter is filled as a registration's is: from the project's own classes, or, for a template the project declares no class of, from the projects of its module that it references. Every project above sees the class through the reference, and so do the generators there, including HotChocolate's. A using alias would have to be written into every project. And the alias of one generator is seen by the compiler, and not by the other generators. The same applies to the class in the project it is generated into. Another generator in that project does not see it. So a module of one project that names the records in such a generator's attributes or resolvers keeps an alias with exactly that name there, and gets no generated class.
  • Named as your class is. The application's class has your class's name, without the type parameters. It is the name your documentation uses, whatever the module is called. It does not clash with your class, because C# tells the two apart by their type parameters, even in a file that imports your namespace. So name the generic class for what it holds, SubscriptionManager and not Manager. Classes with one name from two packages would clash in an application that uses both. Your package asks only for its own types. An [assembly: TemplateFacade] that another assembly declares for your types has no effect.
  • Two modules, two names. An application with two modules that each declare your classes gets two classes with that name. They clash in the projects that see both, the host first. The toolkit reports this in such a project with DDD00075, together with the line that gives one of them its own name. The application writes that line in the project that declares that module's classes: [assembly: TemplateFacadeName("SubscriptionManager", "ShopSubscriptions")]. Every project above it then takes that module's manager as ShopSubscriptions. Nothing in your package changes for this. An application may also set the name without a second module. A line that changes nothing is DDD00076.
  • A class only some applications get. OnlyWith = typeof(...) on the assembly attribute names the template of a class that an application may leave out. The class is written only where that template has a class, and a project without one hears nothing. OnlyWith on a [TemplateRegistration] does the same for a registration. Tenancy's InvitationManager and AddTenancyInvitations are written only where the application declares an invitation class.
  • A wrapper that only saves type arguments. Shorthand = true on a [TemplateRegistration] is for a method whose generic form answers just the same, and that may be called from any project, as a question a row access rule asks. Each project of a module that sees the method gets its own wrapper, since a wrapper is internal. Nothing is reported when the wrapper cannot be closed, so a project that declares only some of the classes hears nothing of a method over the others. Tenancy's questions are marked so: user.CurrentSeatId() for user.CurrentSeatId<SeatId>().
  • A missing class is reported where the classes are, with DDD00065, as information. The causes are a template with no class or with several classes, or a class that does not meet the constraints. Without this diagnostic, the projects above would only learn that the name does not exist. The toolkit does not repeat what one of the classes or one of your registrations already reports in that project as an error, and a class that cannot be generated has its own diagnostic. A name the project already has stays the application's, without a diagnostic: a type or a namespace in the global namespace, or an alias at the top of a file. A type with that name declared in a namespace keeps the class out with DDD00065, because the class would hide that type wherever its namespace is imported.
  • The generic class is not nested in a generic type, and every type parameter is marked [TemplateType], because the application's class leaves none open. For a service or a record it is neither abstract nor sealed, with public constructors. For a name only it is abstract, not static, with a protected constructor. An interface gets an alias. The generator skips a class that does not meet these rules, without a diagnostic. So prove it in the package's own tests, with a project that names the class.

A module that sees only the ids​

A module of the application that uses the package's ids, and declares none of the package's classes, still needs what the package closes over the ids: an access manager, a registration of its checks. The generator cannot find the ids in such a module, because it finds them from the classes. So the package ships a generic assembly attribute that the module writes once, with its ids. Mark the attribute [TemplateIds], and each of its type parameters with the [TemplateType] of the template whose class's id it is:

[TemplateIds]
[AttributeUsage(AttributeTargets.Assembly, Inherited = false)]
public sealed class SubscriptionIdsAttribute<
[TemplateType(typeof(SubscriptionAttribute<>))] TSubscriptionId,
[TemplateType(typeof(InvoiceLineAttribute<>))] TInvoiceLineId> : Attribute
where TSubscriptionId : struct, IEntityId, IEquatable<TSubscriptionId>
where TInvoiceLineId : struct, IEntityId, IEquatable<TInvoiceLineId>;

The module writes the line in its application project, with the ids it sees in the contracts of the module that declares the classes: [assembly: SubscriptionIds<SubscriptionId, InvoiceLineId>]. It then gets:

  • every class of [TemplateFacade] whose type parameters take only ids, written into the project with the line, in its root namespace. In the global namespace it would meet the declaring module's class in every project that sees both, the host first;
  • every [TemplateRegistration] whose type parameters take only ids, in the project of the module that holds its context, below the one with the line.

A registration or a class that takes a class gets nothing there: it belongs to the module that declares the classes. A class that the project or its module declares with a template wins over the line. Constrain each type parameter as the package's classes constrain the id, so the compiler checks the line.

The toolkit's packages​

Tenancy is written this way. Its managers are TenantManager<...>, OrganizationManager<...>, SeatManager<...>, RoleManager<...> and InvitationManager<...>, and an application, including the sample, takes a SeatManager. Its access manager, TenancyAccessManager<...>, is closed over the four ids alone. A module that sees only Tenancy's ids writes [assembly: TenancyIds<TenantId, SeatId, OrganizationUnitId, RoleId>] and gets TenancyAccessManager, db.Tenancy() and services.AddTenancyAccess<TRequests, TContext>(). Its stores, ITenancyStore<...> and IInvitationStore<...>, are interfaces, and the application names them TenancyStore and InvitationStore. Membership's access manager, MembershipAccessManager<TResource>, is generic over the resource alone, which is short to write where the application uses it, so Membership declares no such class.

Working alone and next to another package​

A supporting domain is more useful when it needs less. A package that requires a second package means two packages to adopt. And an application that does the work of the second package in its own way cannot use the first. So a package asks for what it needs, and does not name the package that provides it:

  • Ask through ports of your own. The package asks the application through small interfaces, such as who a caller is to the package, or where something is. Name each interface for what it returns, key it by what it is about, and give it the default that a package used alone needs. The application registers a class that implements the interfaces. In SQL, the equivalent is a function with a logical name, owner/name. The name is text, so nothing is referenced.

  • Let the application connect two packages. When an application uses both packages, the class that implements one package's ports with the other package's queries is application code: a few lines that forward calls. Write it by hand first, in a test host that references both packages, and keep that host and its tests.

  • Then let a generator of the package write it. The generator ships with the package that holds the registrations, runs in the application, and names the other package's types by their metadata names:

    if (compilation.GetTypeByMetadataName("Acme.Calendars.ICalendarQuestions`1") is null)
    {
    return; // the other package is not here: nothing is written, and nothing is missed
    }

    A metadata name is text and needs no reference. So neither package references the other, and the toolkit's own generator names neither. The generator writes where the package's registration is generated, for the same classes. It writes only where the application's types fit, for example where an id of one package is the id that the other package expects. It writes a registration next to the plain one, with a name that says what it connects. This registration refuses what the plain one would read wrongly without a diagnostic.

  • Test it. Test that neither package references the other, down to everything each one loads. Write generator tests for an application with both packages, with one, with types that do not fit, and with several classes in a project.

Membership is written this way next to Tenancy. Without an organization, a member is a user id from your sign-in. In an application with an organization, a member is a seat.

Code the package writes for the application​

Each line that an application writes over a package's types is a line it can get wrong, and a line that the package's docs must explain. A package can take over these lines:

  • Classes that add nothing, and their ids. An application declares each class of a template, even when the class adds nothing, and the id next to it. A package can ship a switch that writes them instead: an assembly attribute of the package, marked [TemplateDefaults] with the templates whose classes it writes.

    [TemplateDefaults(typeof(SubscriptionAttribute<>), typeof(InvoiceLineAttribute<>))]
    [AttributeUsage(AttributeTargets.Assembly, Inherited = false)]
    public sealed class GenerateBillingClassesAttribute : Attribute;

    An application that writes [assembly: GenerateBillingClasses] gets a public Subscription, declared [Subscription<SubscriptionId>], and a public SubscriptionId, declared [EntityId<Guid>], in its root namespace. A class is named after its template's noun. Its id is named after the class, or after the parent's id type parameter where that parameter names another class, as an organization's TTenantId is its tenant's id.

    Nothing is written without the switch, so no code appears unless the application asks for it. What the application declares wins. A class of a template that the application declares, in this project or in a project of its module, is not written. An id with the same name is used, and not written.

    The toolkit's generator writes the declarations and hands them to the toolkit's other generators, through the same providers that the declared classes come from. So a written class and id get the base class, the converters, the registrations and the classes of [TemplateFacade], like declared ones. A template of another package that names a written id as a later type argument is closed over that id. Generators outside the toolkit see the written classes only from the project above, as they see the classes of [TemplateFacade].

    A second attribute with IdsOnly = true writes only the ids, for a module's contracts project. Leave out a class whose declaration turns on a feature, as Tenancy leaves out the invitation (Quick start). What a switch cannot write is reported on the switch with DDD00066.

  • A registration for each thing, next to the main one. A registration can take only the later type arguments of a template, and leave the rest to the call. Membership adds its access check this way. The registration is closed over the resource that the member template names and over that resource's id. It is named after the resource, like the resource's own registration:

    [TemplateRegistration(Name = "Add{TResource}MemberAccess")]
    public static IServiceCollection AddMemberAccess<
    [TemplateType(typeof(MemberAttribute<,,,>), Argument = 3)] TResource,
    [TemplateType(typeof(MemberAttribute<,,,>), Argument = 3, IdOfArgument = true)] TResourceId,
    TRequests>(this IServiceCollection services)
    where TResource : AggregateRoot<TResourceId>
    where TResourceId : struct, IEntityId, IEquatable<TResourceId>
    where TRequests : class, IRequireAccess
    => services.AddAccessCheck<TRequests, MemberAccessCheck<TResource, TResourceId>>();

    The application calls services.AddDocumentMemberAccess<IFilingRequest>(). When a class of the template is declared twice, both registrations that take the template are refused, each under its own name.

  • A request requirement as an attribute. A package that ships cases of AccessRequirement ships an attribute for each case that a request names without computing anything. The attribute's class states which call it stands for. The toolkit's generator writes that call for every request that carries the attribute, as one line in the module's interface. The generator knows no package's attribute by name:

    [StatesRequirement(typeof(BillingAccess), nameof(BillingAccess.OnAccount))] // [OnAccount]: BillingAccess.OnAccount()
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)]
    public sealed class OnAccountAttribute : Attribute;

    [PermissionMarker(typeof(BillingAccess), nameof(BillingAccess.OnLedger), IdOf = typeof(LedgerAttribute<>))]
    [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)]
    public sealed class OnLedgerAttribute(string permission) : Attribute // [OnLedger(permission)]: OnLedger(permission, request.Ledger)
    {
    public string Permission { get; } = permission;
    }
    • [StatesRequirement] passes the attribute's constructor arguments to the call in their order, and the attribute's type arguments as the method's type arguments. For example, [SeenWith<DocumentId>(permission)] becomes MemberAccess.SeenWith<DocumentId>(permission). QueriesOnly = true states that the requirement is for queries only (DDD00082).
    • [PermissionMarker] marks an attribute class as a permission marker. The marker's constructor takes the permission, a string, and the generator reads that argument as the permission. The call takes the permission and the marked member. The generator combines the calls of several markers on one request with AccessRequirement.All. IdOf names the template whose classes' ids the member holds, as a [TemplateType] names one, Argument and IdOfArgument included. A marker on a member of another id is DDD00081, in the project where the application's classes are visible. A marker without its permission is DDD00080.
    • [PermissionMarkerArgument(typeof(OnLedgerAttribute))] marks one more argument of that call, as Membership's [ExpectedVersion] does for [OnResource]. Declare a generic form beside it, such as ExpectedVersionAttribute<TResource>, with the same mark. Its type argument names the class whose id the marker's member is. With it, a request with markers on two kinds of resource names the marker that the argument belongs to.
    • [RequirementArgument(typeof(TAttribute))] does the same for the call of a [StatesRequirement] attribute. The generator hands the marked member to the call after the attribute's constructor arguments. Tenancy's [ForTenant] marks the tenant of a request that carries [Operator], so [Operator] with [ForTenant] TenantId Tenant is TenancyAccess.Operator(request.Tenant). IdOf names the template whose classes' ids the member holds. A marker without its attribute on the request, or on two members, is DDD00102.
    • A property set where a request carries the attribute goes to the call as a named argument, its name with a lower case first letter: [Operator(Scope = "projects")] is TenancyAccess.Operator(scope: "projects"). Give the method a parameter of that name and type, with a default value.
    • A package's check can begin work around the handler of a request it let through. It implements IBeginsWork beside IAccessCheck. The behavior the generator writes, and a dispatcher of your own, call AccessChecks<TRequests>.BeginWork(request, isQuery) after the checks and run the handler inside it (Asking the checks yourself). Begin nothing for a query that would change anything. Tenancy's check of [Operator] begins operator work for a command this way.
    • [assembly: PermissionWithoutMarker(typeof(...), nameof(...))] names the call that the core's [RequiresPermission(permission)] becomes. That attribute names a permission without any marker. One package of an application declares this call. Tenancy declares it, for the whole tenant.

    The compiler checks the call's types in the written line. So a declaration that does not fit is the package's to find, in its own tests of a request that carries the attribute.

    An application stacks your attribute next to others, and all of them apply: AccessRequirement.All of their calls, in the order they are written (Stacked attributes are all). So a module's own attribute declared this way stacks like yours. The marks state three more things, each with a default:

    • Implies = [typeof(SignedInAttribute)], on any of the three marks, names the requirement attributes that yours already requires. One of them stacked next to yours is then a warning, DDD00084. Tenancy's [Operator] names [SignedIn], and its permission cases name [InTenant].
    • OwnAlternative = nameof(...) on [PermissionMarker] states that the marked member is what the request acts on, so [OrOwn] may stand next to it. It names a method of your factory, with the same parameters as the marker's method. The case it returns is what a caller who acts on its own value must still meet. Membership's [OnResource] names MemberAccess.Sees, so a caller acting on its own value still sees the resource. [OrOwn] next to it is Any(All(Own(member), Sees(permission, resource)), On(permission, resource)). Beside several markers, [OrOwn<TResource>] names the marker it pairs with. Without OwnAlternative, [OrOwn] paired with the marker is DDD00085.
    • A type that your package declares the caller's own, with an IOwnedByCurrentUser<TValue> it registers, goes in [assembly: OwnedByCurrentUser(typeof(...))]. For an id that the application declares with your template, write [assembly: OwnedByCurrentUser(IdOf = typeof(SeatAggregateAttribute<>))], as Tenancy does for its seat id. The generator then reports [OrOwn] on any other type, DDD00086. Implies there names what a caller with such a value already meets, as Tenancy names [InTenant] and [SignedIn] for its seat id. Register the handle with TryAdd, so a host's own handle, registered before yours with AddOwnedByCurrentUser, stays. A handle registered after yours replaces it.
  • The package's texts. The registration that knows the refusal codes offers the texts for them. An application that localizes its refusals adds no line: services.AddFailureTexts<MembershipFailures>(codes.TextKeys). The offer changes nothing for an application that does not localize, and the application's own texts come first (Texts a package's registration offers).

  • What a context needs, added by the one call. A package whose storage needs an interceptor on the application's contexts registers it as a part of a context, next to the code it belongs to: services.AddContextPart(...). UseDDDToolkit adds it to every context, in its place. The application names it nowhere, and a module added later gets it from the same call. Where a context cannot work without the part, because of what the package mapped into its model, the mapping states this with RequireContextPart. A context without the part is then refused at its first save and at start-up. Tenancy's save check does both (A part of your own).

  • A line on the application's own class. Membership's generator writes the member list on the resource that a member class names, from the collection, the owner and the refusal codes that the resource declares. Such a generator ships with the package that declares the template, and not with the package that holds the registrations. It runs where the classes are declared, in a domain project, which references no storage. It names the package's types by their metadata names, as text. It follows three rules:

    • It guesses nothing. It writes only when each thing it needs is the only one of its kind on the class. Its docs list those shapes.
    • It keeps what the application wrote. A class that declares the line itself, under any name, is left alone and gets no diagnostic. So the hand-written form is available for every other shape.
    • It reports what it cannot write. A class that has no line and cannot get one is told what is in the way. This is a warning, because the class compiles and is only not usable yet: DDD00059.
  • What every module states, collected where the modules are composed. Tenancy's generator writes a module's list of permissions from the [Permissions] classes the module declares. It reads the list of every module in the assemblies that a project references. It writes those lists, with the call that registers them, into each project that declares no module with DDD_Module or [assembly: Module], the host among them (Declaring a module's permissions). So a module states once what two programs need, and no project lists the modules. A list that a module writes by hand would state its permissions twice. It is an error where it is declared: DDD00095.

A generator of the package ships inside the package it belongs to, in analyzers/dotnet/cs, and is not a package of its own. So it never arrives in another version than the types it names as text. The package references the generator's project only to build it first, and packs its assembly:

<ItemGroup>
<ProjectReference Include="..\Acme.Subscriptions.Analyzers\Acme.Subscriptions.Analyzers.csproj" ReferenceOutputAssembly="false" PrivateAssets="all" />
<None Include="..\Acme.Subscriptions.Analyzers\bin\$(Configuration)\netstandard2.0\Acme.Subscriptions.Analyzers.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>

An application that references only a package above it, the storage package or the Postgres package, still gets the generator. The compiler receives the analyzers of every package that the application depends on. So the generator also runs in every project above the one it is for, such as an application's infrastructure project and its host, and must write nothing there. It writes for what the project itself declares, or where the registration it belongs to is written. Membership's two generators ship this way. So does Tenancy's generator, which writes in the host, because the host composes the modules.

A build from the packed packages proves that the generators arrive and write nowhere else. It builds an application in three projects:

  • a domain project on the two domain packages;
  • an infrastructure project on the two Postgres packages;
  • a host above them, which receives every generator, gets nothing from Membership's generators, and gets the modules' permissions from Tenancy's generator.

Requirements​

  • The parent is a partial class, like every entity (DDD00005, DDD00002). It is abstract, and has the id as its first type parameter, constrained where TId : IEntityId, IEquatable<TId>. It is not nested in a generic type (DDD00042).

  • The template's first type argument, which the application chooses, is an entity id (DDD00043). Every type argument that a template supplies, its own and the ids it takes from other classes, meets the parent's constraints, such as struct (DDD00053).

  • A template fills every type parameter of its parent exactly once, with its own type arguments and its [TemplateArgument]s. A parameter that takes the application's class is not constrained new() (DDD00046). DDD00046 is reported on the attribute when the package builds. Type arguments that the attribute has beyond what its parent takes are its own, for a registration to take.

  • A [TemplateArgument] finds exactly one class (DDD00044, DDD00045), and a class it takes meets the parent's constraints (DDD00048). DDD00044 has a code fix that declares the missing class.

  • Each [TemplateType] of a [TemplateRegistration] method finds exactly one class (DDD00049, DDD00045). For the one template of the method that sets AllowSeveral = true, it finds one class per registration, each with its own name. Also (DDD00050):

    • a class, an id or a later type argument that it takes meets the method's constraints;
    • a type argument whose id it takes is an entity or an aggregate root;
    • a type that the registration is named after has a name.

    The method is public, static and generic. Each [TemplateType] names a template attribute. An Argument is one that the attribute has, and is used without Take = Type. A Name has its braces around type parameters that carry [TemplateType], and is a valid name once they are filled. The declaring type is named in [assembly: TemplateRegistrations]. The generator skips a method that does not meet these rules, so the package's own tests of the call without type arguments must show it.

  • A template that a [TemplateDefaults] switch names is a template attribute with one type argument, the id. The generator cannot tell what a later type argument should be, and skips a template that has one. So the package's own tests of the switch must show it. A class or an id that the switch would write where something of the application is in the way is not written (DDD00066).

  • A class is declared in one way only: with a template, [AggregateRoot<T>], [Entity<T>], [AggregateRootBase] or [EntityBase], never two of them (DDD00047; [AggregateRoot<T>] with [Entity<T>] is DDD00009).

  • A parent does not derive from another parent. There is one level of parent.

  • The package is no module. It becomes part of whichever module of an application declares a class with its templates, and that module names the package's types freely. The package sets no DDD_Module, and its generated code is named after its assembly. Or it sets one for the name, with <DDD_DeclareModule>false</DDD_DeclareModule> next to it, as Tenancy's and Membership's packages do. If the package were a module, every module that names one of its unpublished types would get DDD00022 (A module named by its folder).