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
What’s inside
Section titled “What’s inside”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).
Folder map
Section titled “Folder map”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/
- …
How a module plugs in
Section titled “How a module plugs in”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:
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.
Domain building blocks
Section titled “Domain building blocks”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.
Data-access helpers
Section titled “Data-access helpers”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.
Configuration
Section titled “Configuration”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.
Rules for adding to Shared
Section titled “Rules for adding to Shared”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:
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.
dotnet test src/AppointMe.Shared.Tests