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