Skip to content

Common Mistakes

These are the most common issues developers encounter when working with Pragmatic.Abstractions. Each section shows the wrong approach, the correct approach, and explains why.


1. Adding ASP.NET Core Dependencies to Abstractions

Section titled “1. Adding ASP.NET Core Dependencies to Abstractions”

Wrong:

// In Pragmatic.Abstractions — adding an interface that uses ASP.NET Core types
using Microsoft.AspNetCore.Http;
namespace Pragmatic.Identity;
public interface ICurrentUserAccessor
{
ICurrentUser FromHttpContext(HttpContext context);
}

Result: Any project that references Abstractions now transitively depends on Microsoft.AspNetCore.Http. Console apps, background workers, and test projects that only need the interfaces are forced to pull in the entire ASP.NET Core stack.

Right:

// In Pragmatic.Identity.AspNetCore — the ASP.NET-specific adapter
using Microsoft.AspNetCore.Http;
using Pragmatic.Identity;
namespace Pragmatic.Identity.AspNetCore;
public class ClaimsPrincipalUserAccessor : ICurrentUser
{
// Maps ClaimsPrincipal to ICurrentUser
}

Keep the interface (ICurrentUser) in Abstractions. Keep the ASP.NET-specific implementation in the module that depends on ASP.NET Core.

Why: Abstractions is the dependency root. Every module references it. Adding framework-specific types here forces that framework onto every consumer, defeating the purpose of the abstraction layer.


2. Putting Implementation Logic in Abstractions

Section titled “2. Putting Implementation Logic in Abstractions”

Wrong:

// In Pragmatic.Abstractions — adding caching logic
namespace Pragmatic.Caching;
public class InMemoryCacheStack : ICacheStack
{
private readonly ConcurrentDictionary<string, object> _cache = new();
public ValueTask<T> GetOrSetAsync<T>(string key, Func<CancellationToken, ValueTask<T>> factory,
CacheEntryOptions? options = null, CancellationToken ct = default)
{
// Full caching implementation...
}
// 200+ lines of implementation
}

Result: Abstractions grows from a lightweight contract package into a runtime dependency. Modules that only need the interface now carry the implementation and its transitive dependencies.

Right:

// In Pragmatic.Abstractions — only the contract
namespace Pragmatic.Caching;
public interface ICacheStack
{
ValueTask<T> GetOrSetAsync<T>(string key, Func<CancellationToken, ValueTask<T>> factory,
CacheEntryOptions? options = null, CancellationToken ct = default);
// ... other members
}
// In Pragmatic.Caching — the implementation
namespace Pragmatic.Caching;
public class HybridCacheStack : ICacheStack
{
// Full implementation here
}

Why: Abstractions must contain only contracts (interfaces, attributes, records, enums) and trivially minimal implementations (null-object singletons). Anything with real logic belongs in the module that provides the runtime behavior.

The only exceptions are:

  • Null-object singletons (AnonymousUser, NullUserAuthorization, UnresolvedTenantContext) that return empty/false/null for every member.
  • Stateless extension methods (CurrentUserExtensions, ServiceCollectionDecorateExtensions).
  • Telemetry helpers (ActivityHelper).

3. Using Pragmatic.Abstractions.* Namespaces

Section titled “3. Using Pragmatic.Abstractions.* Namespaces”

Wrong:

// Consumer code — wrong namespace
using Pragmatic.Abstractions.Identity;
public class MyService(ICurrentUser user)
{
// Compile error: ICurrentUser is not in Pragmatic.Abstractions.Identity
}

Right:

using Pragmatic.Identity;
public class MyService(ICurrentUser user)
{
// Works — ICurrentUser lives in Pragmatic.Identity
}

Why: The .csproj declares <RootNamespace>Pragmatic</RootNamespace>. All types live under Pragmatic.* namespaces, not Pragmatic.Abstractions.*. The namespace matches the domain (Identity, Events, Persistence), not the package name. This is intentional: when you later add the full Pragmatic.Identity package, you do not need to change any using statements.


4. Depending on Concrete Modules Instead of Abstractions

Section titled “4. Depending on Concrete Modules Instead of Abstractions”

Wrong:

// In a domain service library — referencing the full module for just the interface
<ProjectReference Include="...\Pragmatic.Persistence.EFCore.csproj" />
// Only uses IRepository, but now depends on EF Core transitively
using Pragmatic.Persistence.Repository;
public class OrderService(IRepository<Order, Guid> orders)
{
// ...
}

Result: The domain service library now transitively depends on EF Core, even though it only uses the IRepository interface. If you ever want to swap the persistence provider (or test without a database), you carry the EF Core dependency.

Right:

// Reference Abstractions, not the full module
<ProjectReference Include="...\Pragmatic.Abstractions.csproj" />
using Pragmatic.Persistence.Repository;
public class OrderService(IRepository<Order, Guid> orders)
{
// Same code, but no EF Core dependency
}

Why: Domain service libraries and module runtime packages should depend on Abstractions for interface types. Only the host project (the final executable) should reference the concrete implementation packages like Pragmatic.Persistence.EFCore or Pragmatic.Identity.AspNetCore.


5. Using typeof() Instead of Generic Attributes

Section titled “5. Using typeof() Instead of Generic Attributes”

Wrong:

[Include(typeof(BookingModule), typeof(AppDatabase))]
[UsePackage(typeof(IdentityLocalPackage))]
[RemoteBoundary(typeof(BillingModule))]
public class AppHost { }

Compile result: These non-generic overloads do not exist. The Abstractions package only provides generic versions.

Right:

[Include<BookingModule, AppDatabase>]
[UsePackage<IdentityLocalPackage>]
[RemoteBoundary<BillingModule>]
public class AppHost { }

Why: Abstractions follows the “generic over typeof” convention for all attributes. Generic attributes provide:

  • Compile-time type checking. [Include<T>] constrains T : class. [Include<T, TDb>] constrains TDb : PragmaticDatabase. The compiler catches type errors immediately.
  • IntelliSense support. You get autocomplete for the type parameter.
  • No runtime reflection. The SG reads generic type arguments directly from the syntax tree.

6. Adding a Single-Module Type to Abstractions

Section titled “6. Adding a Single-Module Type to Abstractions”

Wrong:

// In Pragmatic.Abstractions — an interface only used by Pragmatic.Authorization
namespace Pragmatic.Authorization;
public interface IWildcardMatcher
{
bool Matches(string pattern, string value);
}

Result: The type is consumed by exactly one module. It does not break any circular dependency. It clutters Abstractions with a type that has no cross-module consumers.

Right:

// In Pragmatic.Authorization — where it belongs
namespace Pragmatic.Authorization;
internal static class WildcardMatcher
{
public static bool Matches(ReadOnlySpan<char> pattern, ReadOnlySpan<char> value) { /* ... */ }
}

Why: A type belongs in Abstractions only if it is consumed by two or more modules that must not depend on each other. Single-module types belong in that module, even if they are “general purpose.” The decision to promote a type to Abstractions should be driven by actual cross-module consumption, not by speculation that other modules might need it.


7. Forgetting the Null-Object When Adding a New Interface

Section titled “7. Forgetting the Null-Object When Adding a New Interface”

Wrong:

// In Pragmatic.Abstractions — new interface without a null-object
namespace Pragmatic.FeatureFlags;
public interface IFeatureFlagStore
{
Task<bool> IsEnabledAsync(string flagName, FeatureFlagContext? context, CancellationToken ct);
// ...
}
// No null-object fallback provided
// Consumer must handle null or throw
public class MyService(IFeatureFlagStore? store)
{
public async Task DoWork()
{
if (store is null)
{
// What do we do? Default to enabled? Disabled? Throw?
}
}
}

Result: Every consumer must decide independently how to handle a missing registration. Some will throw, some will default to true, some to false. Behavior is inconsistent.

Right:

When an interface has a natural “do nothing” or “anonymous” state, provide a null-object in Abstractions:

// Null-object: all flags disabled, no definitions, no watches
public sealed class NullFeatureFlagStore : IFeatureFlagStore
{
public static readonly NullFeatureFlagStore Instance = new();
private NullFeatureFlagStore() { }
public Task<bool> IsEnabledAsync(string flagName, FeatureFlagContext? context, CancellationToken ct)
=> Task.FromResult(false);
// ...
}

Why: The null-object pattern ensures consistent behavior when a service is not registered. Consumers can depend on the interface without null checks. The DI container can register the null-object as a fallback.

Not every interface needs a null-object. IRepository<T, TId> does not have one because there is no sensible “do nothing” behavior for persistence. But identity, authorization, tenant context, and feature flags all have natural default states.


8. Breaking an Existing Interface Without Default Implementation

Section titled “8. Breaking an Existing Interface Without Default Implementation”

Wrong:

// Adding a required member to an existing interface
public interface ICurrentUser
{
string Id { get; }
string? DisplayName { get; }
bool IsAuthenticated { get; }
PrincipalKind Kind { get; }
string? TenantId { get; }
IReadOnlyDictionary<string, IReadOnlyList<string>> Claims { get; }
string? ImpersonatedBy { get; }
IUserAuthorization Authorization { get; }
IAuthenticationContext Authentication { get; }
// NEW: required member without default
IUserProfile Profile { get; } // Breaks all existing implementations!
}

Result: Every existing ICurrentUser implementation (AnonymousUser, ClaimsPrincipalUserAccessor, test mocks, etc.) stops compiling because it does not implement Profile.

Right:

public interface ICurrentUser
{
// ... existing members ...
// NEW: with default implementation — non-breaking
IUserProfile? Profile => null;
}

Why: Abstractions is the dependency root of the entire ecosystem. A breaking change to any interface forces every module and every consumer to update simultaneously. Default interface implementations (C# 8+) allow adding new members without breaking existing implementors. The default should represent the “before this feature existed” state — typically null, false, or empty.


9. Mismatching Namespace and Folder Structure

Section titled “9. Mismatching Namespace and Folder Structure”

Wrong:

File: src/Pragmatic.Abstractions/Authorization/IPermissionChecker.cs
namespace Pragmatic.Identity; // Wrong namespace — file is in Authorization/ folder
public interface IPermissionChecker { /* ... */ }

Result: The type compiles but lives in an unexpected namespace. Consumers find it via IntelliSense under Pragmatic.Identity while the file is in the Authorization/ folder. This causes confusion when navigating the source.

Right:

namespace Pragmatic.Authorization; // Matches the folder structure
public interface IPermissionChecker { /* ... */ }

Why: Abstractions follows the convention: namespace = folder structure under the Pragmatic root namespace. The Authorization/ folder maps to Pragmatic.Authorization. The Identity/ folder maps to Pragmatic.Identity. When a type is moved between folders, its namespace must be updated to match.


10. Registering AnonymousUser as Scoped Instead of Singleton

Section titled “10. Registering AnonymousUser as Scoped Instead of Singleton”

Wrong:

// In a startup step — registering the fallback as scoped
services.AddScoped<ICurrentUser>(_ => AnonymousUser.Instance);

Result: Works, but creates unnecessary overhead. A new scope resolution occurs per request even though the object is immutable. When authentication middleware later replaces the registration with a real scoped ICurrentUser, the scoped fallback may interfere with DI ordering.

Right:

// Register as singleton — it is an immutable singleton by design
services.AddSingleton<ICurrentUser>(AnonymousUser.Instance);

Or, more typically, do not register AnonymousUser at all. The authentication middleware registers the real ICurrentUser per request. AnonymousUser.Instance is used directly in code that needs a fallback:

var user = serviceProvider.GetService<ICurrentUser>() ?? AnonymousUser.Instance;

Why: All null-objects in Abstractions (AnonymousUser, NullUserAuthorization, NullAuthenticationContext, UnresolvedTenantContext, FullAccessUserAuthorization) are sealed classes with private constructors and a public static readonly Instance field. They are immutable. If registered in DI, they should be singletons.


MistakeSymptom
ASP.NET Core reference in AbstractionsConsole apps and test projects pull the web stack
Implementation logic in AbstractionsPackage grows, carries runtime dependencies
Pragmatic.Abstractions.* namespaceCompile error: type not found
Concrete module dependency for interfacesUnnecessary transitive dependencies (EF Core, etc.)
typeof() in attributesCompile error: non-generic overloads do not exist
Single-module type in AbstractionsPackage clutter, no cross-module benefit
Missing null-objectConsumers handle missing registrations inconsistently
Breaking interface without defaultAll existing implementations stop compiling
Namespace/folder mismatchConfusing navigation, unexpected type locations
Scoped registration for null-objectsUnnecessary per-request overhead for immutable singletons