Describe your domain. Let the compiler write the application around it.
Think about the last feature you added to a .NET application, whether you wrote the code yourself or asked a coding agent to build it.
You wanted to express something about your business: who can approve a request, when a reservation can be confirmed, what makes an invoice valid.
Implementing that decision also meant connecting it to the rest of the application: persistence, validation, authorization, endpoints, mappings, registrations. Perhaps you wrote that code. Perhaps your agent generated it in seconds. Either way, it became part of your codebase: code to understand, review, test, and keep consistent as the application changes.
AI makes plumbing cheap to produce. It does not make it free to own.
Much of that plumbing follows mechanically from decisions you have already made: the entity an operation acts on, the permission it requires, the errors it can return.
What if those decisions were explicit enough for the compiler to derive the connections and regenerate them whenever the declarations change?
That is the idea behind Pragmatic Design.
Meet Pragmatic
Pragmatic Design is a .NET metaprogramming framework for business applications, built around a shared compile-time model. You write what is specific to your business: entities, the operations on them, the rules they obey. The generator derives the mechanical parts around them as plain C# at build time: persistence, HTTP endpoints, validation, authorization, events, caching, DI wiring, OpenAPI.
One generator to rule them all, one generator to find them, one generator to bring them all, and at compile time bind them.
Because it is one generator reading one model of your application, the pieces finally know about each other. The endpoint knows which errors the operation can return. The permission check uses the permission you declared. The OpenAPI document lists exactly the responses the code can produce.
What it looks like
This is a real operation from the Showcase, the hotel-booking sample in the repository. It confirms a reservation. Two documentation attributes, [UseCase] and [Rule], are left out.
[Endpoint(HttpVerb.Post, "/{id}/confirm")]
[EndpointGroup<ReservationsGroup>]
[RequirePermission(BookingPermissions.Reservation.Update)]
[Mutation(Mode = MutationMode.Update)]
[TransitionsTo<ReservationStatus>(ReservationStatus.Confirmed)]
public partial class ConfirmReservationMutation : Mutation<Reservation, ConflictError> { }
That is the whole thing. No handler body, no registration, no mapping profile, not even an Id. An update addresses an existing reservation, so the generator adds the key and binds {id} from the route to it.
Read it from top to bottom and it tells you what the feature is: a POST endpoint for users who can update reservations, which moves a reservation to Confirmed and answers 409 Conflict when the reservation cannot move there.
From those lines, the build produces the endpoint with typed route binding, the authorization check, loading the reservation, the state transition, the commit, and the OpenAPI description of every response, including 409.
The entity it works on is declared the same way:
[Entity]
[Auditable] // created/updated by and at, stamped on every save
[SoftDelete] // deleted rows disappear from every query
[StateMachine<ReservationStatus>] // status transitions guarded at compile time
[Raises<ReservationCreated>] // a domain event when one is created
public partial class Reservation : DomainEventSource, IEntity
{
[FutureDate]
public DateTimeOffset CheckIn { get; private set; }
[GreaterThanProperty(nameof(CheckIn))]
public DateTimeOffset CheckOut { get; private set; }
public ReservationStatus Status { get; private set; } = ReservationStatus.Pending;
}
In the Showcase, the full version of this entity, which also declares comments, tags and attachments, and two operations on it produce 116 generated files. Most come from the declarations that ask for the most. Comments, tags and attachments add 71 files because each brings its own entity, operations and endpoints. The entity itself adds 22, its read-only API resource adds 14, and the two operations add 9.
You did not write them, you do not maintain them, and they cannot drift from the declarations they came from.
No magic, just C#
"Generated" can sound like "hidden". It is the opposite here. Turn on <EmitCompilerGeneratedFiles> and the output is on disk, in plain C# you can read and step through in the debugger. Here is an excerpt of the endpoint above:
var builder = endpoints.MapPost("/{id}/confirm", (RequestDelegate)(async httpContext =>
{
// typed binding of {id}, then the mutation pipeline
}));
builder.RequireAuthorization(policy => policy.AddRequirements(
new PragmaticPermissionRequirement(new[] { "booking.reservation.update" }, PermissionMode.All)));
builder.WithMetadata(new ProducesResponseTypeMetadata(409, typeof(ProblemDetails), new[] { "application/problem+json" }));
No reflection, no assembly scanning, no runtime discovery. Everything is wired while the project builds. If two declarations do not fit together, you find out from a compiler diagnostic with an ID and a location, not from a failure in production. There are over 400 of those checks.
Here is one. Change the confirm operation's Mode from Update to Delete, keep the transition, and the build stops on the attribute:
error PRAG0468: 'ConfirmReservationMutation' declares [TransitionsTo] but is not an Update mutation:
a Create starts in the initial state, and a Delete or a Restore has a lifecycle of its own.
Declare Mode = MutationMode.Update, or remove the attribute.
This is not a syntax error. It is a combination of declarations that makes no sense, caught before anything runs.
From monolith to services without rewriting the modules
The Showcase has four modules: Accounts, Booking, Catalog and Billing. Here is the host that runs them all in one process:
[Module]
[Include<AccountsModule, ShowcaseAppDatabase>]
[Include<BillingModule, ShowcaseFinancialDatabase>]
[Include<BookingModule, ShowcaseAppDatabase>]
[Include<CatalogModule, ShowcaseAppDatabase>]
public sealed class ShowcaseHostModule;
And here is the host that runs the same application with Billing moved to its own service:
[Module]
[Include<AccountsModule, ShowcaseAppDatabase>]
[Include<BookingModule, ShowcaseAppDatabase>]
[Include<CatalogModule, ShowcaseAppDatabase>]
[RemoteBoundary<BillingModule>]
public sealed class ShowcaseDistributedModule;
Billing's line is the one that changed: [Include] became [RemoteBoundary]. (Both hosts also declare pipeline steps, and the monolith an audit package, left out here.)
The modules did not change at all. When Booking calls Billing, it calls the same typed interface it always did. In the monolith, the generator wires that call in-process. In the distributed host, it generates a typed HTTP invoker that sends the same operation to the Billing service and brings back the same result. Billing gets a small host of its own, declared the same way, with its own database.
Both topologies are in the repository, and both have their own test suites.
To be fair, distribution brings decisions a monolith never has to make, such as how hosts find each other and how a cache invalidation reaches the other host. Those still live in the host's setup as explicit choices. What does not happen is touching the business code.
Where a module runs becomes a deployment decision, not a rewrite. You can start with a monolith without betting the architecture on it.
Be explicit. It's free now.
For years, good design has lost to its own verbosity.
We know a reservation should have a real state machine, not a string field that anything can overwrite. We know operations should return typed errors, not throw exceptions and hope someone maps them to the right status code. We know permissions should be fine-grained, rows should know who owns them, and changes should be audited. We know what a proper domain model looks like.
And then we look at how much code each of those decisions costs: once to write, forever to maintain. So we cut corners. An anemic model here, a generic 500 there, one coarse [Authorize] for the whole controller. Not because we chose a worse design, but because the better one was too expensive.
When the expensive part is generated, the math flips. In Pragmatic, each of those decisions is one line:
// On the entity
[StateMachine<ReservationStatus>] // a real state machine
[HasOwner] // every user sees their own rows
[Auditable] // who changed what, and when
public partial class Reservation : DomainEventSource, IEntity { /* … */ }
// On the operation
[RequirePermission(BookingPermissions.Reservation.Update)] // a fine-grained permission
public partial class ConfirmReservationMutation
: Mutation<Reservation, ConflictError> { /* … */ } // a typed error, which becomes a 409
Being explicit is no longer the costly option. It is the short one. So the verbosity that is left in your code is the useful kind: lines that say something about your domain. The mechanical kind, the lines that only connect things, disappears.
That is also what makes patterns like Domain-Driven Design approachable for teams that always found them too heavy. The ceremony was the problem, not the ideas.
Built for you, and for your coding agent
"My AI agent writes the boilerplate now" is a fair objection. But AI makes plumbing cheap to write, not cheap to own. Every line an agent writes still has to be read, reviewed and kept consistent, and an agent writes the same plumbing slightly differently every time.
With Pragmatic, the agent works where probabilistic intelligence is useful: understanding requirements, modeling the domain, expressing intent. The compiler handles the deterministic consequences.
When the agent declares something that does not fit, the compiler says so immediately, with a location, and the agent can close that loop on its own. There is less code to generate and less to review. That this also means fewer tokens is what I expect, not something I have measured yet.
And explicit code is exactly what an agent reads best. A declaration that says "this moves a reservation to Confirmed, for users who can update reservations, and fails with a conflict" is context an agent can use. A hundred lines of wiring around it are context it has to wade through.
So that agents know the framework as well as the code they read, Pragmatic ships agent skills for Claude Code, Codex and any agent that reads SKILL.md.
Make intent explicit. Make its mechanical consequences deterministic.
More intent. Less plumbing.
Where it fits
Pragmatic pays off where many operations follow recurring shapes: load an entity, check a permission, apply a change, answer with a typed error. That is most of a line-of-business application, but not all of it.
What cannot be declared, a rule of your own or a domain method, stays code you write, in a body the generated pipeline calls. A domain made mostly of one-off workflows gains less. And there is something to learn: the declarations are the API, read by a compiler that refuses what does not fit.
Try it
Two ways in, depending on how you like to work:
- With an agent. Install the skills and describe the application you want: Build with an agent.
- By hand. The step-by-step recipe goes from an empty folder to a CRUD API on PostgreSQL.
Or just browse the Showcase and its generated code.
Pragmatic currently targets .NET 10. We plan to support .NET 11 and build on new language and platform capabilities that make domain intent more explicit, from C# 15 union types and closed hierarchies to richer serialization and persistence support. As the platform evolves, the goal stays the same: declare the decisions, derive the plumbing.
It is available as 1.0.0-alpha on nuget.org. The core is stable, other modules are still moving. The foundations and the generator are MIT; the framework runtime is source-available and free for small businesses, personal and educational use (licensing).
Help shape Pragmatic.
Pragmatic is still in alpha, and your feedback can help shape what it becomes. Try an example or bring your own use case. Tell me what breaks, what gets in your way, and what you wish it could do.
Whether it’s a bug, a missing capability, or an idea for a better API, I’d love to hear it.
Top comments (0)