Boundaries Guide
Boundaries group related domain actions and mutations into a strongly-typed interface, providing module-level composition, transaction isolation, and topology validation.
What is a Boundary?
Section titled “What is a Boundary?”A boundary is a lightweight marker class annotated with [Boundary]. It represents a bounded context in your domain. The source generator scans all [DomainAction] and [Mutation] classes whose namespace matches the boundary’s namespace and generates a typed interface for invoking them.
using Pragmatic.Actions.Attributes;
namespace Showcase.Catalog;
[Boundary]public partial class CatalogBoundary;This generates ICatalogActions with typed invoke methods for every action in the Showcase.Catalog.* namespace tree.
Namespace-Based Capture
Section titled “Namespace-Based Capture”The default assignment rule is namespace prefix matching:
CatalogBoundaryis inShowcase.Catalog- All actions in
Showcase.Catalog.*are captured Showcase.Catalog.Amenities.Mutations.CreateAmenityMutationbelongs toCatalogBoundaryShowcase.Catalog.Properties.Actions.UploadPropertyPhotoActionbelongs toCatalogBoundary
If a sub-namespace defines its own boundary, it is subtracted from the parent. Actions in that sub-namespace belong to the child boundary instead.
Capture Rules
Section titled “Capture Rules”- A boundary captures its namespace AND all sub-namespaces (deep capture)
- If a sub-namespace has its own
[Boundary], it subtracts from the parent - Actions with
Internal = trueorSystem = trueare excluded from the interface - Actions with
[BelongsTo<T>]override namespace assignment
Explicit Assignment
Section titled “Explicit Assignment”Override namespace-based assignment with [BelongsTo<T>]:
[DomainAction][BelongsTo<ShippingBoundary>]public partial class SpecialShipment : DomainAction<ShipmentId>{ // Lives in OrdersBoundary's namespace but belongs to ShippingBoundary}Internal and System Actions
Section titled “Internal and System Actions”Internal Actions
Section titled “Internal Actions”[DomainAction(Internal = true)]public partial class RecalculateTotals : DomainAction<decimal>{ // NOT included in boundary interface // Still goes through the full pipeline // Cannot have [Endpoint]}Internal actions are helper operations called from other actions within the same boundary. They run through the pipeline but are not exposed in the boundary interface.
System Actions
Section titled “System Actions”[DomainAction(System = true)]public partial class ExportReport : DomainAction<byte[]>{ // NOT included in boundary interface // CAN have [Endpoint] for direct HTTP exposure}System actions are infrastructure operations (exports, health checks, diagnostics) that bypass boundary grouping.
Boundary Visibility
Section titled “Boundary Visibility”[Boundary(Visibility = BoundaryVisibility.Public)] // defaultpublic partial class CatalogBoundary;
[Boundary(Visibility = BoundaryVisibility.Internal)]public partial class InternalServiceBoundary;- Public: Generates both public and internal interfaces. The boundary is callable from other assemblies and can be exposed via endpoints or remote boundaries.
- Internal: Generates only an internal interface. The boundary is only callable within the same assembly.
Sub-Boundaries
Section titled “Sub-Boundaries”Large boundaries can be split into sub-groups for better interface segregation:
[SubBoundary(Name = "Reservations", Description = "Reservation management operations")]public class ReservationSubBoundary { }Sub-boundaries generate their own interface (e.g., IReservationsActions) and local implementation. The root boundary interface composes them as properties:
// Generatedpublic interface IBookingActions{ IReservationsActions Reservations { get; } IGuestsActions Guests { get; } // ...}When Name is not specified, it is inferred from the namespace relative to the parent boundary.
Cross-Boundary Read Access
Section titled “Cross-Boundary Read Access”When a boundary needs to read entities from another boundary via SQL join (both must share the same physical database):
[Boundary][ReadAccess<Property>][ReadAccess<RoomType>]public partial class BookingBoundary;Effects:
- The persistence SG adds
DbSet<Property>andDbSet<RoomType>toBookingBoundaryDbContext - Both DbSets are configured with
ExcludeFromMigrations()— migrations don’t create duplicate tables - The composition SG validates at compile time that both boundaries target the same physical database (via topology metadata)
This enables efficient read queries that JOIN across boundaries without HTTP calls:
// In BookingBoundary action:var reservations = await _reservations.Query() .Include(r => r.Property) // Property is from CatalogBoundary .Where(r => r.Property.City == "Rome") .ToListAsync(ct);Cross-Boundary Writes (PRAG0424)
Section titled “Cross-Boundary Writes (PRAG0424)”Reading across boundaries is a join. Writing across them is not: each boundary owns a DbContext, and
DomainActionInvoker saves it once, at the end, only when the action succeeded. Call another
boundary’s actions from inside yours and there are two saves, the inner one first — so a failure after
that point leaves the inner writes committed, with nothing to roll them back.
Measured on a real application: a deliberate failure left five rows in the callee’s table, pointing at a parent row that was never written.
The generator warns when a single invocation commits into more than one store:
PRAG0424: 'WriteStoryAction' writes in more than one boundary within one invocation (IKnowledgeActions). Each boundary saves separately, inner first, so a failure after that point leaves the inner writes committed.Four ways out, in order of preference:
-
Move the work into one boundary. If the two writes are one fact, they belong to one owner.
-
Make the inner step undo itself — the only option that repairs rather than accepts:
[DomainAction][UndoWith<RemoveIngestedText>]public partial class IngestTextAction : DomainAction<IngestResult>;public sealed class RemoveIngestedText(IRepository<Term> terms): ICompensates<IngestResult>{public Task<VoidResult<IError>> Undo(IngestResult committed, CancellationToken ct = default){// remove what the ingestion added}}The invoker registers the undo after its commit succeeds. If an outer action in the same request then fails, its invoker runs the registered undos in reverse order before returning. The compensator is an ordinary scoped service, not an action: the generator registers it, and commits it through the unit of work of the boundary that owns the data.
Best effort, in-request, and that is the whole guarantee. A crash between the inner commit and the compensation leaves the work committed: nothing here is durable and nothing is retried. That is where a saga starts. A compensator that itself fails is logged at
Errorand reported to the caller asCOMPENSATION_FAILED, carrying both the original error and the undo’s — the response says the system is inconsistent rather than only that the operation failed.Each facade method whose action declares an undo is marked
[CompensableStep], and PRAG0424 goes quiet for the callers that invoke those steps. Per method, not per facade: a caller should not have to compensate the operations it never touches.PRAG0425is an error when the declared compensator does not implementICompensates<TReturn>(orICompensatesVoid) for that action — otherwise the declaration would silence PRAG0424 while undoing nothing. -
Make the caller tolerate the leftovers, and say so:
[DomainAction][AcceptsPartialWrites("The glossary keeps unreferenced candidates; a nightly job prunes them.")]public partial class WriteStoryAction : DomainAction<WriteStoryResult>;The reason is required. A decision without one is indistinguishable from silencing the warning, which is the thing the diagnostic exists to prevent.
-
Use a saga when the leftovers are unacceptable and the work genuinely spans boundaries — durable state, retries, and compensation that survives a crash.
The warning fires on more than one commit scope, not on any cross-boundary call: an action that writes nothing itself and calls a single other boundary is atomic, and stays quiet.
Known blind spot. The marker the diagnostic reads ([BoundaryActions<TBoundary>]) is emitted onto
the generated facade, and a facade generated in the compilation being analysed is invisible to the
generator that produced it. Two boundaries declared in the same assembly are therefore not detected.
Every topology the framework produces puts a boundary in its own assembly — calling a facade means
referencing the assembly carrying it — but a same-assembly pair slips through.
Boundary Configuration
Section titled “Boundary Configuration”Local Boundaries
Section titled “Local Boundaries”Local boundaries run in-process with a database connection:
services.AddBoundary<CatalogBoundary>(cfg => cfg .UseLocal() .UseDatabase(opt => opt.UseNpgsql(connectionString)));Each local boundary gets its own IUnitOfWork keyed by boundary type, ensuring transaction isolation.
Remote Boundaries
Section titled “Remote Boundaries”Remote boundaries are accessed via HTTP. The invoker serializes the action and sends it as an HTTP request:
services.AddBoundary<BillingBoundary>(cfg => cfg .UseRemote("https://billing-api.example.com"));This registers a typed HttpClient named after the boundary type. The generated remote invoker uses this client to dispatch actions.
Configuration Validation
Section titled “Configuration Validation”Calling Validate() on a configuration checks:
- Local boundaries must have
DatabaseOptionsset (viaUseDatabase()) - Remote boundaries must have a base URL
Topology Validation
Section titled “Topology Validation”Call ValidateBoundaryTopology() at startup to verify the module dependency graph:
services.AddBoundary<CatalogBoundary>(cfg => cfg.UseLocal().UseDatabase(...));services.AddBoundary<BookingBoundary>(cfg => cfg.UseLocal().UseDatabase(...));services.AddBoundary<BillingBoundary>(cfg => cfg.UseRemote("https://..."));
services.ValidateBoundaryTopology();This validates:
- No circular dependencies — detects cycles in the boundary dependency graph
- All dependencies registered — if BoundaryA depends on BoundaryB (from module metadata), BoundaryB must be registered
- No duplicate registrations — each boundary can only be registered once
Validation errors throw TopologyValidationException with a specific TopologyValidationError:
public enum TopologyValidationError{ CircularDependency, // A -> B -> A MissingDependency, // A requires B, but B is not registered DuplicateBoundary, // Same boundary registered twice BoundaryModeMismatch // Mode conflict}Module Metadata
Section titled “Module Metadata”The source generator produces [PragmaticModuleMetadata] assembly-level attributes that declare:
[assembly: PragmaticModuleMetadata( BoundaryType = typeof(CatalogBoundary), Name = "Catalog", Actions = new[] { typeof(CreateAmenityMutation), typeof(UpdateAmenityMutation), ... }, Entities = new[] { typeof(Amenity), typeof(Property), ... }, Repositories = new[] { typeof(AmenityRepository), ... }, Validators = new[] { typeof(CreateAmenityValidator), ... }, DependsOn = new[] { /* boundary types this module depends on */ }, ReadAccessTypes = new[] { typeof(Property), typeof(RoomType) })]Runtime Discovery
Section titled “Runtime Discovery”ModuleMetadataReader provides methods to discover and query module metadata at runtime:
// Get metadata from a specific assemblyvar metadata = ModuleMetadataReader.GetMetadata(typeof(CatalogBoundary).Assembly);
// Discover all modules across loaded assembliesvar allModules = ModuleMetadataReader.DiscoverAllModules();
// Build the dependency graphvar graph = ModuleMetadataReader.BuildDependencyGraph();
// Get topologically sorted order (dependencies first)var sorted = ModuleMetadataReader.GetTopologicalOrder();Internal Calls and Authorization Bypass
Section titled “Internal Calls and Authorization Bypass”When one action invokes another within the same boundary, the boundary implementation wraps the call in an internal call scope:
// Generated boundary implementation (simplified):public async Task<Result<Guid, IError>> CreateReservationAsync( CreateReservationAction action, CancellationToken ct){ using var scope = _callContext.EnterInternalCall(); return await _createReservationInvoker.InvokeAsync(action, ct);}While IsInternalCall is true:
PermissionAuthorizationFilterskips permission checksPolicyEvaluationFilterskips policy evaluation- Validation still runs (internal calls should still be validated)
This prevents redundant authorization when action A orchestrates action B. The top-level action (called by the endpoint or external code) handles authorization; internal calls trust the caller.
Nesting: ActionCallContext uses a depth counter. Multiple levels of internal calls work correctly — the context only returns to IsInternalCall = false when all scopes are disposed.
Boundary Interface (IBoundary)
Section titled “Boundary Interface (IBoundary)”The IBoundary marker interface is used as a constraint on BoundaryConfiguration<T>:
public interface IBoundary;
public sealed class BoundaryConfiguration<TBoundary> where TBoundary : IBoundary{ public BoundaryMode Mode { get; } public string? RemoteBaseUrl { get; } public Delegate? DatabaseOptions { get; } // ...}Your boundary class does not need to implement IBoundary directly — it is used primarily for the configuration and DI registration constraints.
Boundary Service Registration Extensions
Section titled “Boundary Service Registration Extensions”The BoundaryServiceCollectionExtensions class provides helper methods:
// Register a boundary with configurationservices.AddBoundary<CatalogBoundary>(cfg => cfg.UseLocal().UseDatabase(...));
// Check if a boundary is registeredbool exists = services.HasBoundary<CatalogBoundary>();
// Get configuration for a boundaryvar config = services.GetBoundaryConfiguration<CatalogBoundary>();
// Get all registered boundary configurations (for validation/introspection)var all = services.GetAllBoundaryConfigurations();Real-World Example: Showcase
Section titled “Real-World Example: Showcase”The Showcase application demonstrates a multi-boundary architecture:
Showcase.Catalog/ [CatalogBoundary] Amenities/Mutations/ CreateAmenityMutation, UpdateAmenityMutation, DeleteAmenityMutation Properties/Mutations/ CreatePropertyMutation, UpdatePropertyMutation, DeletePropertyMutation, RestorePropertyMutation RoomTypes/Mutations/ CreateRoomTypeMutation, UpdateRoomTypeMutation CancellationPolicies/ CreateCancellationPolicyMutation, UpdateCancellationPolicyMutation
Showcase.Booking/ [BookingBoundary] [ReadAccess<Property>] [ReadAccess<RoomType>] Reservations/Actions/ CreateReservationAction Reservations/Mutations/ ConfirmReservationMutation, CheckInGuestMutation, CancelReservationMutation Guests/Actions/ SetGuestPreferencesAction Guests/Mutations/ CreateGuestMutation, UpdateGuestMutation
Showcase.Billing/ [BillingBoundary] Actions/ MarkInvoicePaidAction, RefundInvoiceAction Mutations/ CreateDraftInvoiceMutationKey patterns demonstrated:
- Namespace capture: All types under
Showcase.Catalog.*belong toCatalogBoundary - Cross-boundary read:
BookingBoundarydeclares[ReadAccess<Property>]to JOIN with catalog entities - Internal mutation:
CreateDraftInvoiceMutationhas no[Endpoint]— it is invoked programmatically by event handlers - State machine:
CheckInGuestMutationusesApplyAsyncfor controlled state transitions - Full CRUD: Amenities show Create/Update/Delete with permissions and endpoints
- Soft-delete + Restore: Properties show Delete and Restore mutations