Boundaries
Logical partitions for entities, DbContexts, and transaction scopes.
What Is a Boundary?
Section titled “What Is a Boundary?”A boundary is a marker class that groups entities into a logical domain partition. Each boundary maps to:
- A DbContext (with only the entities in that boundary)
- A keyed IUnitOfWork (for transactions)
- A set of repositories (one per entity in the boundary)
// Define boundaries — empty marker classespublic class BillingBoundary;public class BookingBoundary;public class CatalogBoundary;// Assign entities to boundaries[Entity<Guid>][BelongsTo<BillingBoundary>]public partial class Invoice { /* ... */ }
[Entity<Guid>][BelongsTo<BookingBoundary>]public partial class Reservation { /* ... */ }
[Entity<Guid>][BelongsTo<CatalogBoundary>]public partial class Property { /* ... */ }Why Boundaries Exist
Section titled “Why Boundaries Exist”1. Focused DbContexts
Section titled “1. Focused DbContexts”Without boundaries, one DbContext contains every entity in your application. For large applications (50+ entities), this causes:
- Slow model building at startup
- Confusing
IntelliSensewith hundreds ofDbSetproperties - No isolation — any code can query any entity
With boundaries, each DbContext contains only 10-30 related entities:
// Generated: BillingDbContext has only billing entitiespublic class BillingDbContext : PragmaticDbContext{ public DbSet<Invoice> Invoices { get; set; } public DbSet<LineItem> LineItems { get; set; } public DbSet<Fee> Fees { get; set; } // ... only billing entities}2. Transaction Isolation
Section titled “2. Transaction Isolation”SaveChanges() within a boundary is atomic — all changes to entities in that boundary are saved in one transaction. Changes to entities in different boundaries are independent transactions.
This is by design. Boundaries are the unit of transactional consistency. If you need to coordinate across boundaries, use domain events (eventually consistent) or explicit distributed transactions (rarely needed).
3. Database Flexibility
Section titled “3. Database Flexibility”Each boundary can use a different database:
// Billing → PostgreSQLservices.AddBillingDbContext(o => o.UseNpgsql(billingConnection));
// Catalog → PostgreSQL (same or different server)services.AddCatalogDbContext(o => o.UseNpgsql(catalogConnection));
// Analytics → read replicaservices.AddAnalyticsDbContext(o => o.UseNpgsql(replicaConnection));How Boundaries Work in DI
Section titled “How Boundaries Work in DI”The source generator registers boundary-scoped services using keyed DI (a .NET 8+ feature):
// Generated DI registrationservices.AddKeyedScoped<DbContext, BillingDbContext>(typeof(BillingBoundary));services.AddKeyedScoped<IUnitOfWork, BillingUnitOfWork>(typeof(BillingBoundary));
services.AddKeyedScoped<DbContext, BookingDbContext>(typeof(BookingBoundary));services.AddKeyedScoped<IUnitOfWork, BookingUnitOfWork>(typeof(BookingBoundary));Resolving in Mutations
Section titled “Resolving in Mutations”The generated MutationInvoker uses [FromKeyedServices] to get the right unit of work:
public CreateInvoiceMutationInvoker( IRepository<Invoice, Guid> repository, [FromKeyedServices(typeof(BillingBoundary))] IUnitOfWork unitOfWork, IServiceProvider serviceProvider)Resolving in Endpoints
Section titled “Resolving in Endpoints”The generated endpoint handler uses keyed DI for the DbContext:
app.MapGet("api/v1/invoices", async ( [FromKeyedServices(typeof(BillingBoundary))] DbContext dbContext, [FromServices] IQueryExecutor executor, CancellationToken ct) => { /* ... */ });Resolving Manually
Section titled “Resolving Manually”When you need the IUnitOfWork in your own service:
public class InvoiceService( IRepository<Invoice, Guid> invoices, IServiceProvider serviceProvider){ public async Task CreateInvoice(/* ... */, CancellationToken ct) { invoices.Add(invoice); var uow = serviceProvider.GetRequiredKeyedService<IUnitOfWork>(typeof(BillingBoundary)); await uow.SaveChangesAsync(ct); }}Cross-Boundary Relationships
Section titled “Cross-Boundary Relationships”Entities from different boundaries can reference each other via foreign keys:
// Invoice (BillingBoundary) references Reservation (BookingBoundary)[Entity<Guid>][BelongsTo<BillingBoundary>][Relation.ManyToOne<Reservation>]public partial class Invoice{ public Guid ReservationId { get; private set; }}What Works
Section titled “What Works”- FK property —
ReservationIdis generated and stored in the database - Querying by FK —
query.Where(i => i.ReservationId == reservationId)works - Filtering by FK —
[Filter]onReservationIdin a query class works
What Does Not Work
Section titled “What Does Not Work”- Navigation property —
invoice.Reservationis not generated for cross-boundary relations - Include() —
query.Include(i => i.Reservation)does not work (different DbContexts) - Cascade operations — EF Core cascade delete across boundaries is not supported
Loading Cross-Boundary Data
Section titled “Loading Cross-Boundary Data”Load related data separately:
var invoice = await invoiceRepo.GetByIdAsync(invoiceId, ct);var reservation = await reservationRepo.GetByIdAsync(invoice.ReservationId, ct);Or use a query with a join (within the same boundary only).
Boundaries and Actions
Section titled “Boundaries and Actions”The boundary concept appears in two places:
1. Persistence ([BelongsTo<T>])
Section titled “1. Persistence ([BelongsTo<T>])”Defines which DbContext owns the entity. This is the most common use.
[Entity<Guid>][BelongsTo<BillingBoundary>]public partial class Invoice { /* ... */ }2. Actions ([BelongsTo<T>])
Section titled “2. Actions ([BelongsTo<T>])”DomainActions also use [BelongsTo<T>] to route to the correct IUnitOfWork:
[DomainAction][BelongsTo<BillingBoundary>]public partial class CreateInvoiceAction : DomainAction<Invoice>{ public override async Task<Result<Invoice, IError>> Execute(CancellationToken ct) { // The generated invoker uses BillingBoundary's IUnitOfWork }}There are two distinct BelongsToAttribute classes (one in Pragmatic.Persistence.Entity, one in Pragmatic.Actions.Attributes) but they serve the same purpose: routing the operation to the correct boundary’s services.
Boundary Configuration
Section titled “Boundary Configuration”In the Host
Section titled “In the Host”services.AddBillingDbContext(options => options.UseNpgsql(config.GetConnectionString("Billing")));services.AddBillingRepositories();
services.AddBookingDbContext(options => options.UseNpgsql(config.GetConnectionString("Booking")));services.AddBookingRepositories();Migrations
Section titled “Migrations”Each boundary has its own migration set. The SG generates a MigrationDbContext per boundary:
// Generated: BillingMigrationDbContext// Used only for migrations — includes all entities + configuration[Database<Npgsql>][Persist<BillingBoundary>]public partial class BillingMigrationDbContext : MigrationDbContext { }Run migrations per boundary:
dotnet ef migrations add InitBilling --context BillingMigrationDbContextdotnet ef database update --context BillingMigrationDbContextDesign Decisions
Section titled “Design Decisions”Why not one DbContext per entity?
Section titled “Why not one DbContext per entity?”Too granular. You’d need cross-DbContext transactions for every operation that touches two related entities (e.g., Invoice + LineItem). A boundary groups entities that change together.
Why not one DbContext for everything?
Section titled “Why not one DbContext for everything?”Works for small applications. Breaks down at scale — slow startup, no isolation, no database-per-service option. Boundaries let you start monolithic and split later.
When should I create a new boundary?
Section titled “When should I create a new boundary?”| Signal | Action |
|---|---|
| Entities change together in the same transaction | Same boundary |
| Entities are in different domain modules | Different boundaries |
| You need a different database/connection | Different boundaries |
| You need different migration schedules | Different boundaries |
A good starting heuristic: one boundary per module/assembly.
Related Guides
Section titled “Related Guides”- DbContext Generation
- Mutation Pipeline — How mutations use boundary-keyed DI
- Migration Patterns
- Relationships — Cross-boundary relationships