Skip to content

Boundaries

Logical partitions for entities, DbContexts, and transaction scopes.


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 classes
public 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 { /* ... */ }

Without boundaries, one DbContext contains every entity in your application. For large applications (50+ entities), this causes:

  • Slow model building at startup
  • Confusing IntelliSense with hundreds of DbSet properties
  • No isolation — any code can query any entity

With boundaries, each DbContext contains only 10-30 related entities:

// Generated: BillingDbContext has only billing entities
public class BillingDbContext : PragmaticDbContext
{
public DbSet<Invoice> Invoices { get; set; }
public DbSet<LineItem> LineItems { get; set; }
public DbSet<Fee> Fees { get; set; }
// ... only billing entities
}

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).

Each boundary can use a different database:

// Billing → PostgreSQL
services.AddBillingDbContext(o => o.UseNpgsql(billingConnection));
// Catalog → PostgreSQL (same or different server)
services.AddCatalogDbContext(o => o.UseNpgsql(catalogConnection));
// Analytics → read replica
services.AddAnalyticsDbContext(o => o.UseNpgsql(replicaConnection));

The source generator registers boundary-scoped services using keyed DI (a .NET 8+ feature):

// Generated DI registration
services.AddKeyedScoped<DbContext, BillingDbContext>(typeof(BillingBoundary));
services.AddKeyedScoped<IUnitOfWork, BillingUnitOfWork>(typeof(BillingBoundary));
services.AddKeyedScoped<DbContext, BookingDbContext>(typeof(BookingBoundary));
services.AddKeyedScoped<IUnitOfWork, BookingUnitOfWork>(typeof(BookingBoundary));

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)

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
) => { /* ... */ });

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);
}
}

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; }
}
  • FK propertyReservationId is generated and stored in the database
  • Querying by FKquery.Where(i => i.ReservationId == reservationId) works
  • Filtering by FK[Filter] on ReservationId in a query class works
  • Navigation propertyinvoice.Reservation is 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

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).


The boundary concept appears in two places:

Defines which DbContext owns the entity. This is the most common use.

[Entity<Guid>]
[BelongsTo<BillingBoundary>]
public partial class Invoice { /* ... */ }

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.


IStartupStep.ConfigureServices
services.AddBillingDbContext(options =>
options.UseNpgsql(config.GetConnectionString("Billing")));
services.AddBillingRepositories();
services.AddBookingDbContext(options =>
options.UseNpgsql(config.GetConnectionString("Booking")));
services.AddBookingRepositories();

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:

Terminal window
dotnet ef migrations add InitBilling --context BillingMigrationDbContext
dotnet ef database update --context BillingMigrationDbContext

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.

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.

SignalAction
Entities change together in the same transactionSame boundary
Entities are in different domain modulesDifferent boundaries
You need a different database/connectionDifferent boundaries
You need different migration schedulesDifferent boundaries

A good starting heuristic: one boundary per module/assembly.