Skip to content

Design Principles -- Pragmatic.Abstractions

This document explains why Pragmatic.Abstractions exists, what rules govern its contents, and how to decide whether a type belongs here or in a specific module.


The Pragmatic.Design ecosystem comprises many modules (Actions, Persistence, Events, Identity, Authorization, Composition, etc.) that need to communicate through shared contracts. Without a central abstractions package, modules would either:

  1. Depend on each other directly — creating circular dependencies and forcing modules to carry heavy transitive dependencies they do not need.
  2. Duplicate interfaces — leading to incompatible contracts and breaking composition.

Pragmatic.Abstractions solves this by providing a single, lightweight package that all modules can reference. It contains only the contracts (interfaces, attributes, records, enums) that are consumed across module boundaries. It carries no implementation logic, no ASP.NET Core dependency, and no EF Core dependency.


Pragmatic.Abstractions (Layer 0)
^
|
+----------------+------------------+
| | |
Pragmatic.Actions Pragmatic.Events Pragmatic.Persistence.EFCore
Pragmatic.Identity Pragmatic.Authorization ...
(Layer 1-2 modules)

Rule: Abstractions depends on nothing except Pragmatic.Specification (a pure library with no external dependencies) and three Microsoft.Extensions.*.Abstractions packages. Every other module depends on Abstractions, never the reverse.

The three Microsoft abstractions packages are included because IPragmaticBuilder and the DI-related types need IServiceCollection, IConfiguration, and IHostEnvironment. These are stable, widely-used .NET contracts with no runtime coupling.


A type belongs in Pragmatic.Abstractions if it meets all of the following criteria:

CriterionRationale
It is consumed by two or more modules that must not depend on each other.Single-module types belong in that module.
It has zero implementation logic (or trivially minimal, like null-object singletons).Implementation belongs in the module that provides the runtime behavior.
It does not depend on ASP.NET Core, EF Core, or any heavy external library.Abstractions must remain lightweight so even a minimal console app can reference them.
It represents a stable contract that changes infrequently.Volatile types would force cascading updates across all modules.
TypeWhy
ICurrentUserConsumed by Persistence (auditing), Actions (authorization filter), Endpoints (permission checks), Logging (correlation).
IEntity<TId>Consumed by Persistence and the Source Generator. Repository generic constraints reference it.
IClockConsumed by Persistence (auditing timestamps), Actions (lifecycle), Events (OccurredAt), Authorization (temporal roles).
IErrorConsumed by Result, Actions, Endpoints, and the Source Generator.
[Service] attributeConsumed by the Source Generator at compile time and by any module that registers services.
ITenantContextConsumed by Persistence (tenant filter), Caching (tenant-scoped keys), Configuration (tenant overrides).
ICallContextConsumed by Actions (authorization skip for internal calls) and Events (event handlers run as internal calls).
Telemetry tag constantsConsumed by every module that instruments with OpenTelemetry. Centralized to avoid tag name drift.
TypeWhere It BelongsWhy
EfRepository<T, TId>Pragmatic.Persistence.EFCoreIt is an EF Core implementation detail.
MutationInvoker<T>Pragmatic.ActionsIt is Actions runtime logic.
ClaimsPrincipalUserAccessorPragmatic.Identity.AspNetCoreIt depends on ASP.NET Core’s ClaimsPrincipal.
InMemoryEventDispatcherPragmatic.EventsIt is an implementation of IDomainEventDispatcher.
WildcardMatcherPragmatic.AuthorizationIt is authorization-specific utility logic.
HybridCache adapterPragmatic.CachingIt wraps a third-party caching library.

Several interfaces exist in Abstractions specifically to break circular dependencies:

InterfaceProblem It Solves
ICacheStack, CacheEntryOptions, CachePriority, CacheCategoriesCaching types live here so modules (Authorization, Configuration, Endpoints) can depend on the caching abstraction without referencing Pragmatic.Caching. Replaces the former IConfigurationCache with a unified ICacheStack + category routing.
IPragmaticBuilderComposition.Host provides the concrete builder, but every module needs to add Use*() extension methods on the interface. Placing the interface here avoids modules depending on the Host package.
ICallContextActions defines the implementation, but Events needs to check IsInternalCall when dispatching. The interface here lets Events reference it without depending on Actions.

For interfaces that are consumed in contexts where no real implementation may be registered (anonymous requests, no-tenant scenarios), Abstractions provides singleton null-objects:

Null ObjectInterfaceBehavior
AnonymousUserICurrentUserId = empty, IsAuthenticated = false, all authorization checks = false
NullAuthenticationContextIAuthenticationContextAll properties = null/false
NullUserAuthorizationIUserAuthorizationAll checks return false, all collections empty
FullAccessUserAuthorizationIUserAuthorizationAll checks return true (for system-level contexts)
UnresolvedTenantContextITenantContextTenantId = null, IsResolved = false

These are sealed classes with private constructors and a public static readonly Instance field. They are safe to register as singletons in DI.

Design rule: if an interface from Abstractions has a natural “do nothing” or “anonymous” state, provide a null-object here. This prevents consumers from needing to handle null service resolution.


The .csproj declares:

<RootNamespace>Pragmatic</RootNamespace>

This means all types live under Pragmatic.* namespaces (e.g., Pragmatic.Identity.ICurrentUser, Pragmatic.Events.IDomainEvent), not under Pragmatic.Abstractions.*. This is intentional: when a consumer writes using Pragmatic.Identity;, they get the same namespace regardless of whether they reference the Abstractions package or the full Identity package. The abstraction and its implementation share a namespace, which simplifies imports.


Abstractions favors static abstract interface members (C# 11+) for compile-time safety:

ContractPattern
IPermissionstatic abstract string Name { get; }
IRolestatic abstract string Name { get; } + static abstract IReadOnlyList<string> DefaultPermissions { get; }
IGroupstatic abstract string Name { get; } + static abstract IReadOnlyList<string> DefaultRoles { get; }
IFeatureFlagstatic abstract string Name { get; }
IPackageDefinitionstatic abstract string PackageName { get; }

Each permission, role, group, and feature flag is a type, not a string. This enables:

  • Compile-time validation (the SG reads these types and validates permission names)
  • IntelliSense and refactoring support
  • Generic extension methods like store.IsEnabledAsync<LoyaltyDiscount>()
  • SG-generated registries and constants

The generic attribute convention follows: [ExplicitPermission<TPermission>] rather than [ExplicitPermission(typeof(TPermission))].


Attributes in Abstractions follow these rules:

  1. Generic over typeof: Always prefer [Attr<T>] over [Attr(typeof(T))].
  2. Minimal properties: Only include properties that affect SG output or runtime behavior. Documentation belongs in XML comments.
  3. No implementation logic: Attributes are pure data carriers.
  4. Inherited = false: Most attributes are not inherited because each type opts in explicitly.
  5. AllowMultiple only when needed: [Include<T>] and [IncludeModule<T>] allow multiple because a host or module may include many modules. Most other attributes are single-use.

Telemetry tag constants are centralized here to prevent tag name drift across modules. Each module instruments its own Activity spans but uses the same tag names.

Naming convention:

PrefixSource
Standard OTel (db.*, exception.*, cache.*)OpenTelemetry semantic conventions
pragmatic.*Framework-specific extensions

When adding new tags, check the OpenTelemetry semantic conventions first. Only create pragmatic.* tags for concepts that have no OTel equivalent.


Because every module in the ecosystem depends on Abstractions:

  • Breaking changes in Abstractions cascade to all modules. Avoid breaking changes unless absolutely necessary.
  • New interfaces are additive (non-breaking). Modules only consume the interfaces they need.
  • New members on existing interfaces should use default interface implementations when possible to avoid breaking existing implementors.
  • Deprecation via [Obsolete] with a migration path (see DependsOnAttribute as an example).

The package follows the same version as the Pragmatic.Design ecosystem. All packages are versioned together.


Checklist: Adding a New Type to Abstractions

Section titled “Checklist: Adding a New Type to Abstractions”

Before adding a type to this package, verify:

  • It is consumed by at least two modules that should not depend on each other.
  • It has no implementation logic (or only trivial null-object logic).
  • It does not require ASP.NET Core, EF Core, or other heavy dependencies.
  • It represents a stable contract unlikely to change frequently.
  • Its namespace follows the folder structure under Pragmatic.*.
  • If it is an interface with a natural “empty” state, a null-object singleton is provided.
  • If it is a new marker interface (like IPermission), it uses static abstract members for compile-time safety.
  • If it is an attribute, it follows the generic-first convention.