announcement Harry

Why we built Pragmatic .NET

After fourteen years of shipping enterprise .NET, we stopped rebuilding the same skeleton on every project. Here's what we kept, what we threw out, and why the result is a template and not a framework.

Stacked architectural diagram of eight concentric layers in deep navy with thin coral hairlines

For the first ten years of Aegislabs, the way a .NET project started looked roughly the same. Someone would clone last quarter’s repo. They’d strip out the domain code. They’d rename the namespaces. They’d discover that half the references pointed to a NuGet package we’d removed years ago, and the other half pointed to a private feed that no one had logged into since the previous lead left. Two weeks later, a new project would exist — technically — with a solution file, a DI container, four layers, and a partial identity scheme.

We kept telling ourselves this was fine because every project is different and the scaffold has to be tuned anyway. But the patterns we were “tuning” never actually changed. The first commit of every serious project reached the same place: Clean Architecture layers, a typed CQRS seam, a migrations folder, auth middleware, health checks, and some version of the same appsettings.Development.json.

So at some point we asked the boring question: why is this not a template?

Templates aren’t frameworks

Part of the hesitation was historical. Most attempts inside Indonesian enterprise shops to build a “standard .NET starter” over the last decade ended as frameworks — they grew their own DSLs, their own annotations, their own custom bus implementations, their own reasons to exist. After two years no one understood them any more and the code that used them was effectively unportable.

Pragmatic .NET exists as an explicit rebuttal of that pattern. It is a solution template, not a framework:

  • It does not introduce any custom abstractions over the .NET BCL, EF Core, ASP.NET Core, or Mediator. Anything a reader already knows about these libraries continues to be true.
  • It does not hide configuration behind magic attributes. If a behaviour is wired up, it is wired up in code you can read, in a file named after the thing it configures.
  • It has no plug-in system. If you need a capability the template doesn’t have, you add the library yourself, the same way you would on any .NET project. The template removes the decision about which layer to add it to, not the decision about whether to add it.

A framework asks you to learn it. A template asks you to delete the parts you don’t want.

What made the cut

The version 2.0 skeleton ships with four layers — Domain, Application, Infrastructure, Presentation — and these defaults, which we’ve converged on across roughly sixty production projects:

  • JWT authentication with refresh-token rotation and a real revocation path. Not a “todo: implement refresh” stub.
  • EF Core with a migrations project that can target SQL Server, PostgreSQL, and MySQL from the same codebase. We do not assume SQL Server anymore; Indonesian state-owned enterprises have been moving to PostgreSQL for three years.
  • MediatR-style command/query handlers with pipeline behaviours for validation (FluentValidation), authorization, and logging. One seam, not three.
  • Problem Details error responses (RFC 7807) on every error path, including validation failures. The default ASP.NET ModelStateError shape is never returned — callers get one consistent envelope.
  • Health checks and OpenTelemetry wired up out of the box.
  • CI/CD: a GitHub Actions workflow that builds, tests, and publishes a Docker image, plus a Helm chart for the most common Kubernetes deployment shape we see at our customers.

Everything else — multi-tenancy, event sourcing, feature flags, a message broker — is a layer you add on purpose, not by default. If we ever stop being able to justify why something is in the template, it leaves the template.

What we cut, and why

The first version of Pragmatic .NET — internally called “Aegis_NET v1” around 2020 — carried a lot of things that looked useful at the time and turned out not to be:

  • A custom logging wrapper. It added nothing on top of ILogger<T> and made every consumer do string.Format instead of structured logging. Cut.
  • A generic repository pattern. It obscured EF Core’s own change tracking without adding test isolation we weren’t already getting from IDbContextFactory. Cut.
  • A “multi-language” abstraction over English and Bahasa Indonesia labels. Every project needed labels in different places, and the abstraction always ended up being bypassed. Cut.
  • Domain events fired through an in-memory bus by default. We never once shipped a system where in-memory bus was the right answer for events that mattered. Cut.

Each of these decisions was uncontroversial after we made it and controversial before we made it. That’s roughly the test for whether cutting something from a template is actually progress.

Who it’s for

Pragmatic .NET is for engineering teams that:

  1. Have shipped at least one .NET production service before, so they know what they’re reading.
  2. Need to start a new service on a tight deadline (weeks, not months).
  3. Are going to be responsible for it for the next three to five years, and would prefer the 2030-version to still be readable.

If you’re evaluating it for those reasons, the fastest way to understand the trade-offs is to read the Program.cs of the sample project. Every decision we made is visible from there in ten minutes of scrolling. No magic, no conventions you don’t see, no configuration hidden inside a NuGet package.

If after that read you still have questions, book a free 30-minute consultation. We’d rather spend that time talking about your actual situation than writing more marketing.


Harry is the account and delivery lead for Pragmatic .NET customers at Aegislabs. Over the past four years he has led thirty-plus production deployments of the template at banks, telcos, and state-owned enterprises across Indonesia and Southeast Asia.

← All insights