Skip to content
StarLive demo →
ModuleChecked against main · 31 Oct 20268 min read

Shared kernel

AppointMe.Shared is the .NET shared kernel every module references. It holds IDs, value objects, tenancy, permissions and data-access helpers, and never references a module back.

Project
src/AppointMe.Shared
References
NuGet packages only, no module
Holds
IDs, value objects, tenancy, permissions, helpers
Touch it when
You add a value object or helper

Shared holds the building blocks every module uses, and each area has a concept page that explains it in depth.

Area Folder Key types Explained in
Tenancy Companies/ CompanyId, ICurrentCompany, ICompanyDetection, InvokeForCompany, WithCompany Multi-tenancy
Identity Authentication/ IIdentity, UserIdentity, SystemIdentity, AnonymousIdentity, IIdentityResolver, SuperAdminRegistry Identity & principal
Authorization Authorization/ Permission, SystemPermission, PermissionRegistry, IDefaultGrantPolicy, IPrincipal.Require, Role, SystemRole Permissions
Domain Domain/ AggregateRoot, IDomainEvent, Id.NewId(), value objects, domain exceptions IDs & value objects
Endpoints Endpoints/ IEndpoint, AddEndpoints, MapEndpoints API conventions
Data access Database/, Pagination/ IDbConnectionFactory, ExtSqlBuilder, PagedResult, AddDatabaseMigration CQRS: EF writes, Dapper reads
Jobs Jobs/ IRecurringJobRegistrar, IRecurringJobScheduler, AddRecurringJobs Background jobs

The rest is small: UserId in Users/, the options classes in Configuration/, and three helpers in Utilities/ (Tokenize, FullOuterJoin, TimeProvider.Today).

  • Directorysrc/AppointMe.Shared/
    • DirectoryAuthentication/ # IIdentity, SuperAdminRegistry
      • …
    • DirectoryAuthorization/
      • DirectoryPermissions/ # Permission, PermissionRegistry, AddPermissions
        • …
      • DirectoryPrincipals/ # IPrincipal, Require
        • …
      • DirectoryRoles/ # Role, SystemRole
        • …
    • DirectoryCompanies/ # CompanyId, ICurrentCompany, detection
      • …
    • DirectoryConfiguration/ # AddSharedModule, options
      • …
    • DirectoryDatabase/
      • DirectoryDapper/ # ExtSqlBuilder, type handlers
        • …
      • DirectoryMigrations/ # AddDatabaseMigration
        • …
    • DirectoryDomain/
      • DirectoryCommon/ # value objects
        • …
      • DirectoryErrors/ # domain exceptions
        • …
      • AggregateRoot.cs
      • …
    • DirectoryEndpoints/ # IEndpoint, MapEndpoints
      • …
    • DirectoryJobs/
      • …
    • DirectoryPagination/
      • …
    • DirectoryUsers/ # UserId
      • …
    • DirectoryUtilities/
      • …
  • Directorysrc/AppointMe.Shared.Tests/
    • …

A module’s Add…Module() method calls a handful of Shared helpers that scan its assembly, so new endpoints, permissions and jobs are picked up without a central list.

Helper What it does
AddEndpoints(assembly) Registers every IEndpoint class in the assembly, public or not, as transient.
MapEndpoints() Called once by the API host. Maps every registered endpoint into one api/v1 route group with API version 1.
AddPermissions(assembly) Registers each public static Permission field on a static class whose name ends in Permissions, and every IDefaultGrantPolicy.
AddRecurringJobs(assembly) Registers every IRecurringJobRegistrar. The API host’s Hangfire setup calls them at startup.
AddDatabaseMigration<TContext>() Adds the context to a list a hosted service migrates at startup, in registration order. DisableDatabaseMigrations() turns that off for build-time code generation.
AddSharedModule(configuration) Called once by the API host. Registers the Dapper type handlers, FrontendOptions, HybridCache, TimeProvider.System and ConnectionStrings.

This is why every route starts with /api/v1:

src/AppointMe.Shared/Endpoints/EndpointsApplicationBuilderExtensions.csView source ↗
var apiVersionSet = app
.NewApiVersionSet()
.HasApiVersion(new ApiVersion(1))
.ReportApiVersions()
.Build();
var versionedGroup = app
.MapGroup("api/v1")
.WithApiVersionSet(apiVersionSet);
var endpoints = app.Services
.GetRequiredService<IEnumerable<IEndpoint>>();
foreach (var endpoint in endpoints)
{
endpoint.MapEndpoint(versionedGroup);
}

A walkthrough of a new module’s registration is in Add a module.

Aggregates raise events, IDs are version 7 GUIDs, and value objects validate through factories that throw ValidationException.

AggregateRoot is an abstract record with an Events list and a Raise(IDomainEvent) method. The API host tells Wolverine to publish those events when EF Core saves the aggregate (PublishDomainEventsFromEntityFrameworkCore<AggregateRoot>); see Domain events & messaging. Id.NewId() returns Guid.CreateVersion7(), and modules call it bare through a global using static.

Value object Create enforces
Email Trimmed and lower-cased, not blank, 200 chars at most, a valid MailAddress.
PersonName First name required, each part 100 chars at most, title-cased. FromFullName splits on spaces.
DateOfBirth Not in the future and not more than 150 years ago, measured with a TimeProvider.
LongString Not blank, 4,000 chars at most.
Gender Male, Female or Other, case-insensitive, through Parse and ParseOrNull.
DateTimeOffsetPeriod Both ends in UTC, end after start.
StatsBucket day, week or month, through Parse.

TimeZoneInfo.Create(id) adds the same kind of factory to the BCL type, and StatsBucketing turns instants into day, week (Monday-first) or month buckets in a time zone. The four exceptions in Domain/Errors derive from DomainException, which carries an optional Code. The API host maps ValidationException to 400, AccessDeniedException to 403, NotFoundException to 404 and ConflictException to 409; see API conventions.

The Dapper side of every module uses the same pagination and search helpers, so list endpoints behave alike.

Helper What it does
IDbConnectionFactory, SqlConnectionFactory Opens a SqlConnection on ConnectionStrings:AppointMeSql. Each module registers one.
PaginationRequest The limit and page query parameters. ToPaginationFilter() defaults them to 10 and 1.
PaginationFilter Clamps Limit to 1–100 and Page to at least 1, and exposes Offset.
ExtSqlBuilder.AddPagination Fills a template’s /**pagination**/ (OFFSET … FETCH NEXT) and /**totalcount**/ (COUNT(*) OVER ()) placeholders.
QueryWithPaginationAsync<T> Runs that template and splits the TotalCount column off each row.
WhereSearch and Tokenize One LIKE clause per search word. Tokenize lower-cases, removes duplicates and keeps at most 30 words.
UtcDateTimeOffsetTypeHandler Returns every DateTimeOffset in UTC, whether the column is datetime2 (Booking) or datetimeoffset (the other modules).

A Dapper query still has to filter by company itself: see Multi-tenancy.

Shared defines the settings types the modules read; AddSharedModule binds two of them and the API host binds the rest.

Key Type Notes
ConnectionStrings:AppointMeSql ConnectionStrings Read once. A missing value stops startup with Connection string 'AppointMeSql' not found.
Frontend:BaseUrl FrontendOptions A required URL, validated on start. InvitationUrl is this URL plus /auth/login.
Authentication:SuperAdmins SuperAdmin.ConfigurationSection The API host reads this list of emails into SuperAdminRegistry. Matching ignores case.
Demo:Enabled, Demo:User:* DemoOptions, DemoUserOptions Only the types live here. The API host binds them for the demo login.

Every key with its default is in the Configuration reference.

Shared is a leaf: it references no module, owns no data and exposes no routes, so anything that needs a table, an endpoint or a module’s types belongs in a module.

Rule What the code shows
No reference to a module AppointMe.Shared.csproj has package references only. Every module references Shared, so a reference back would be circular.
No tables, routes or handlers Shared has no DbContext, no IEndpoint implementation and no message handler. It holds the contracts for them: IEndpoint, IDomainEvent, AddDatabaseMigration.
Cross-module messages go in a module’s .Contracts project Shared defines only the IDomainEvent marker. CompanyRegistered lives in AppointMe.Organizations.Contracts, and CustomerRegisteredEvent in AppointMe.Crm.Contracts.
Value objects: unvalidated constructor, validating factory The primary constructor is for EF Core and Dapper. Untrusted input goes through Create or CreateOrNull in a companion …Factory class. The repo’s CLAUDE.md states this rule.
Invalid input throws ValidationException Every factory does, and the API host turns it into 400.

Email shows the shape. The record has no logic, and the factory is a C# 14 extension block on the type:

src/AppointMe.Shared/Domain/Common/Email.csView source ↗
public sealed record Email(string Value)
{
public const int MaxLength = 200;
}
public static class EmailFactory
{
extension(Email)
{
public static Email? CreateOrNull(string? email)
{
return email is null ? null : Email.Create(email);
}
public static Email Create(string email)
{
email = email.Trim().ToLowerInvariant();
// …
return new Email(mailAddress.Address);
}
}
}

To add a value object, follow the same steps:

Step Where What
1. The record Domain/Common/<Name>.cs A sealed record with the raw value and a MaxLength or similar constants.
2. The factory The same file A <Name>Factory static class with extension(<Name>), holding Create and CreateOrNull that throw ValidationException.
3. The mapping Each module’s *TypeConfiguration HasConversion from the value and back with the primary constructor, as UserTypeConfiguration does for Email.
4. The tests src/AppointMe.Shared.Tests/Domain/Common A test class like EmailTests.
Why are value-object constructors left unvalidated?1 min · optional

EF Core and Dapper rebuild value objects from rows that were validated when they were written. Running the rules again on every read costs time, and a rule you tighten later would make old rows unreadable.

So validation happens once, at the boundary, where untrusted input enters: request handlers and cross-module event handlers call Create, and infrastructure calls the constructor.

AppointMe.Shared.Tests covers the value objects and the small rules in authorization, tenancy and pagination.

Folder Covers
Domain/Common/ Email, PersonName, DateOfBirth, Gender, DateTimeOffsetPeriod, StatsBucket and StatsBucketing, including daylight-saving transitions and business-day counts.
Authorization/ Role.Create returns the canonical built-in roles. Require throws and lists every required permission code.
Companies/ ToTenantId is the bare GUID, and WithCompany tags a message with the tenant.
Pagination/, Authentication/ PaginationFilter clamping and SuperAdminRegistry matching.

The project also supplies the Specification base class that the Organizations permission tests build on. LongString has no tests yet.

Terminal window
dotnet test src/AppointMe.Shared.Tests