Learnixo
Back to blog
AI Systemsintermediate

Six Opinionated Choices — Why This Template Deviates From Defaults

The six deliberate architectural decisions in the Clean Architecture template: no MediatR, Scalar over Swagger, HybridCache over IDistributedCache, Result pattern over exceptions, no repository pattern, and .slnx format — with the reasoning behind each.

Asma Hafeez KhanMay 16, 20266 min read
Clean Architecture.NETArchitectureDesign DecisionsTrade-offs
Share:𝕏

Why Opinionated Templates

Most templates give you options. This one makes decisions. Each decision removes a category of debate from your team and lets you start building.

Every decision documented here is deliberate, reversible, and has a specific reason. If the reason does not apply to your context, change the decision.


Decision 1: Manual CQRS Over MediatR

The choice: Command and query handlers are plain C# classes, injected directly. No ISender.Send() indirection.

Why:

C#
// With MediatR — you cannot Ctrl+Click to the handler
var result = await _sender.Send(new CreatePatientCommand(...));

// Manual CQRS — direct navigation, explicit dependency
var result = await _createPatient.Handle(new CreatePatientCommand(...), ct);
  • Direct dependencies make the call graph navigable
  • No pipeline behavior magic to search for when debugging
  • No registration errors that only manifest at runtime
  • One fewer NuGet dependency and one fewer abstraction to explain to new developers

When to reverse it: When you have 50+ command types and want a unified pipeline for validation, logging, and caching across all of them. MediatR's pipeline behaviors are genuinely useful at scale.


Decision 2: Scalar Over Swagger UI

The choice: Scalar.AspNetCore instead of Swashbuckle.

Why:

Swashbuckle (Swagger UI):
  - Requires a separate library to wire up .NET's OpenAPI
  - UI is dated, not searchable, hard to read for large APIs
  - Active but not the reference implementation for .NET 9+ OpenAPI

Scalar:
  - Works with .NET 9's built-in OpenAPI generation (no Swashbuckle needed)
  - Modern, searchable, syntax-highlighted UI
  - Built-in API client for sending requests
  - One package, one line of setup

When to reverse it: If you need Swagger UI features like request interceptors, a corporate-branded UI, or you are already using Swashbuckle and the migration cost is not worth it.


Decision 3: HybridCache Over IDistributedCache

The choice: Microsoft.Extensions.Caching.Hybrid instead of IMemoryCache + IDistributedCache separately.

Why:

Without HybridCache:
  IMemoryCache  → fast, not shared across instances, stampede-prone
  IDistributedCache → shared, slow (serialization + network), stampede-prone
  You write the two-layer logic yourself

With HybridCache:
  One API, two layers (L1 in-memory + L2 Redis), stampede protection built-in
  Tag-based invalidation for cross-instance consistency

Production scenario: 4 API instances behind a load balancer. A prescription update on instance 1 must be visible on instance 2 within milliseconds. HybridCache's tag-based invalidation via Redis makes this automatic. With separate caches per instance, you get 75% stale-data rate until TTL expires.

When to reverse it: If you do not need distributed caching (single instance, or stateless), IMemoryCache alone is simpler.


Decision 4: Result Pattern Over Exceptions

The choice: All business rule violations return Result<T>. Exceptions are reserved for unexpected failures only.

Why:

C#
// Exception-based:
throw new DuplicatePatientException();   // travels through stack, logs as error, triggers alert

// Result-based:
return Result.Failure<PatientId>(PatientErrors.MRNAlreadyExists);  // deliberate, local, cheap
  • Exceptions are expensive (stack unwinding) and should signal bugs, not expected conditions
  • Business failures in the return type make all failure modes visible at compile time
  • Controllers can handle typed errors explicitly with Match() instead of catching exception types
  • Error monitoring systems only alert on genuine unexpected failures

Production issue I've seen: A system throwing NotFoundException for every "patient not found" lookup was generating 12,000 exception logs per day in a busy emergency department — all expected 404s. On-call engineers were desensitized to the noise. A real production error got buried in exception logs and went unnoticed for 4 hours.

When to reverse it: If you are in a codebase where exceptions are pervasive and the migration cost to Result is higher than the operational benefit.


Decision 5: No Repository Pattern

The choice: EF Core's DbSet<T> and IQueryable<T> are used as the repository. Typed repository interfaces are used only for complex, reusable queries.

Why:

Generic repository (IRepository):
  - Wraps EF Core's already-repository-like DbSet
  - Forces a choice: IEnumerable (loads everything) or IQueryable (leaks EF Core)
  - Generic GetAll() tempts developers to filter in memory
  - Adds a layer with no real value for most CRUD operations

Direct EF Core:
  - IQueryable composes filters, projections, and pagination into SQL
  - AsNoTracking() for reads is explicit
  - No intermediate layer between the handler and the database abstraction

When to reverse it: If you have a genuine need to swap persistence implementations (SQL → NoSQL), or if your integration test strategy requires a fake repository rather than an in-memory database.


Decision 6: .slnx Solution Format

The choice: .slnx (XML solution format) instead of the legacy .sln text format.

Why:

.sln format:
  Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "src\Domain\...", "{GUID}"
  GlobalSection(SolutionConfigurationPlatforms) = preSolution
  ...
  # GUID noise, non-deterministic ordering, merge conflicts in team settings

.slnx format:
  
    
      
    
  
  # Human-readable, diff-friendly, deterministic

When to reverse it: If your tooling does not support .slnx (requires Visual Studio 2022 17.10+ or recent Rider). Check team tooling before migrating.


Summary Table

Decision              Default          Template Choice       Key Benefit
────────────────────────────────────────────────────────────────────────
CQRS dispatch         MediatR          Manual handlers       Navigable, explicit
API docs              Swagger/Swashbuckle Scalar             Modern UI, less setup
Caching               IMemoryCache +   HybridCache           Two-layer, stampede protection
                      IDistributedCache
Error handling        Exceptions        Result             Typed failures, no noise
Persistence           Repository     Direct EF Core        Composable IQueryable
Solution format       .sln              .slnx                 Readable, merge-friendly

PRO TIP — Document Your Own Deviations

When your team deviates from this template — "we added MediatR because we have 80 command types" or "we kept the repository for testability" — document the decision and the reason in an ARCHITECTURE.md file at the root. Undocumented decisions get undone by future developers who do not know why they were made.


Key Takeaway

These six decisions reduce friction at the cost of flexibility. Manual CQRS is simpler than MediatR until you have a pipeline. The Result pattern is more explicit than exceptions until you have a large team where exception-based code is already established. Know which trade-off you are making and document it — that is what makes an opinionated template valuable.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.