Skip to content

Architecture

Pragmatic Design is a composition of modules. Each module is independently useful and works alone. Put them together and they compose — the unified Source Generator sees the combination and wires cross-module glue automatically.

┌─────────────────────┐
│ Product / App │ Showcase, your services
├─────────────────────┤
│ Medium Block │ Identity.Local, Comments, Tags, Attachments
├─────────────────────┤
│ Building Block │ Result, Actions, Persistence, Messaging, …
└─────────────────────┘
TierScopeExamples
Building Block (Layer 0-2)Toolkit infrastructure. The dev uses it to build the domain.Result, Actions, Persistence, Composition, Events, Messaging, Jobs
Medium BlockCross-cutting packages that ship complete. Include, configure, use.Identity.Local, Comments, Tags, Attachments
Product / AppComposition of blocks into a running application.Showcase, your own services

Inside the Building Block tier, modules are stratified — each layer depends only on the layer below.

Layer 0 (Foundation) Layer 1 (Capabilities) Layer 2 (Integration)
├── Result ├── Validation ├── Actions
├── Ensure ├── Mapping ├── Endpoints
├── DependencyInjection ├── Specification ├── Persistence
├── Abstractions ├── Internationalization ├── Composition
└── Identifiers ├── Caching ├── Events
├── Resilience ├── Authorization
├── Configuration ├── Identity
└── Temporal ├── Messaging
├── Jobs
├── Migrations
├── MultiTenancy
└── Logging
  • Foundation (Layer 0) has no dependencies on other Pragmatic modules.
  • Capabilities (Layer 1) depend only on Foundation.
  • Integration (Layer 2) can depend on Capabilities and Foundation, and compose with each other.

Supporting modules live on the side: Storage, FeatureFlags, Discovery, Patch, ControlPlane, Documents.*, Imaging, Email, Notifications.

This is the core architectural insight: adding a NuGet reference is the configuration.

When you add Pragmatic.Persistence.EFCore to your project, the Source Generator detects it via FeatureDetector (by looking for a marker type) and starts generating repositories, entity configurations, and query filters for every [Entity] class in your code. Remove the package — the generated code disappears.

No feature flags. No configuration files. No if statements in startup. Your .csproj is the source of truth.

When multiple modules are detected together, cross-module composition kicks in:

  • [DomainAction] + Pragmatic.Persistence.EFCore → the Action can declare [LoadEntity<Reservation>] and the generator emits a repository lookup before ExecuteAsync.
  • [DomainAction] + Pragmatic.Authorization → the Action invoker automatically runs a permission check filter before executing.
  • IDomainEventHandler<T> + Pragmatic.Persistence.EFCore + Pragmatic.Messaging.EFCore → domain events saved to the DbContext are automatically copied to the outbox and delivered after commit.

None of these need explicit wiring. You added the package, you wrote the declaration, the generator did the rest.

Topology (compile-time) → Module Strategy (Program.cs) → Business Wiring (IStartupStep)
SG auto-detect IPragmaticBuilder IStartupStep

You declare the structure with attributes; the generator reads it.

[Module] // application module
public sealed class BookingModule;
[Boundary] // logical boundary inside a module
public sealed class ReservationsBoundary;
[BelongsTo<ReservationsBoundary>] // action/entity/endpoint assignment
[DomainAction]
[Endpoint(HttpVerb.Post, "/reservations")]
public partial class CreateReservation
: IDomainAction<ReservationResult> { ... }

No code to write. No list to keep in sync. If you moved the file or renamed the class, topology follows.

Tier 2 — Module Strategy (IPragmaticBuilder)

Section titled “Tier 2 — Module Strategy (IPragmaticBuilder)”

For modules with a pluggable backend, you pick it in Program.cs:

await PragmaticApp.RunAsync(args, builder => builder
.UseIdentity(id => id.UseLocal())
.UseAuthorization(authz => authz
.MapRole<BookingManager>()
.UsePermissionCache(TimeSpan.FromMinutes(5)))
.UseMessaging(m => m.UseChannels().EnableAuditing())
.UseJobs(j => j.UseEfCoreStore<AppDbContext>())
.UseStorage(s => s.UseLocalDisk(".data")));

Every Use*() is optional: if you skip it, the module registers a working default (in-memory store, allow-all policy, passthrough cache).

See the Configuration reference for the full list.

Anything that’s specific to your service — domain services, custom filters, HTTP pipeline additions — goes in one or more IStartupStep implementations. The Source Generator discovers them automatically, no registration needed:

public sealed class BookingStartup : IStartupStep
{
public int Order => 100; // ascending: lower runs first (default 1000)
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped<IRoomPricingService, DynamicPricingService>();
services.AddDataScopeRule<HighSeasonRoomScopeRule, Room>();
}
public void ConfigurePipeline(WebApplication app)
{
app.MapHub<ReservationsHub>("/hubs/reservations");
}
}

“Where does this go?”

QuestionAnswer
”Where does this action / entity belong?”Topology — [BelongsTo<TBoundary>] attribute
”Which cache provider?” / “Which auth handler?”Module Strategy — builder.UseXxx()
”I need a domain service registered”Business Wiring — IStartupStep.ConfigureServices
”I need to add a SignalR hub / custom middleware”Business Wiring — IStartupStep.ConfigurePipeline
”I need a handler for a domain event”Nothing — declare IDomainEventHandler<T>, SG discovers it
”Change how a module itself behaves”Tier 2 if supported; otherwise open an issue