Skip to content

Boundaries Guide

Boundaries group related domain actions and mutations into a strongly-typed interface, providing module-level composition, transaction isolation, and topology validation.

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.


The default assignment rule is namespace prefix matching:

  • CatalogBoundary is in Showcase.Catalog
  • All actions in Showcase.Catalog.* are captured
  • Showcase.Catalog.Amenities.Mutations.CreateAmenityMutation belongs to CatalogBoundary
  • Showcase.Catalog.Properties.Actions.UploadPropertyPhotoAction belongs to CatalogBoundary

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.

  1. A boundary captures its namespace AND all sub-namespaces (deep capture)
  2. If a sub-namespace has its own [Boundary], it subtracts from the parent
  3. Actions with Internal = true or System = true are excluded from the interface
  4. Actions with [BelongsTo<T>] override namespace 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
}

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

[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 = BoundaryVisibility.Public)] // default
public 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.

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:

// Generated
public interface IBookingActions
{
IReservationsActions Reservations { get; }
IGuestsActions Guests { get; }
// ...
}

When Name is not specified, it is inferred from the namespace relative to the parent boundary.


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> and DbSet<RoomType> to BookingBoundaryDbContext
  • 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);

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:

  1. Move the work into one boundary. If the two writes are one fact, they belong to one owner.

  2. 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 Error and reported to the caller as COMPENSATION_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.

    PRAG0425 is an error when the declared compensator does not implement ICompensates<TReturn> (or ICompensatesVoid) for that action — otherwise the declaration would silence PRAG0424 while undoing nothing.

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

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


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

Calling Validate() on a configuration checks:

  • Local boundaries must have DatabaseOptions set (via UseDatabase())
  • Remote boundaries must have a base URL

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:

  1. No circular dependencies — detects cycles in the boundary dependency graph
  2. All dependencies registered — if BoundaryA depends on BoundaryB (from module metadata), BoundaryB must be registered
  3. 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
}

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

ModuleMetadataReader provides methods to discover and query module metadata at runtime:

// Get metadata from a specific assembly
var metadata = ModuleMetadataReader.GetMetadata(typeof(CatalogBoundary).Assembly);
// Discover all modules across loaded assemblies
var allModules = ModuleMetadataReader.DiscoverAllModules();
// Build the dependency graph
var graph = ModuleMetadataReader.BuildDependencyGraph();
// Get topologically sorted order (dependencies first)
var sorted = ModuleMetadataReader.GetTopologicalOrder();

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:

  • PermissionAuthorizationFilter skips permission checks
  • PolicyEvaluationFilter skips 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.


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.


The BoundaryServiceCollectionExtensions class provides helper methods:

// Register a boundary with configuration
services.AddBoundary<CatalogBoundary>(cfg => cfg.UseLocal().UseDatabase(...));
// Check if a boundary is registered
bool exists = services.HasBoundary<CatalogBoundary>();
// Get configuration for a boundary
var config = services.GetBoundaryConfiguration<CatalogBoundary>();
// Get all registered boundary configurations (for validation/introspection)
var all = services.GetAllBoundaryConfigurations();

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/ CreateDraftInvoiceMutation

Key patterns demonstrated:

  • Namespace capture: All types under Showcase.Catalog.* belong to CatalogBoundary
  • Cross-boundary read: BookingBoundary declares [ReadAccess<Property>] to JOIN with catalog entities
  • Internal mutation: CreateDraftInvoiceMutation has no [Endpoint] — it is invoked programmatically by event handlers
  • State machine: CheckInGuestMutation uses ApplyAsync for controlled state transitions
  • Full CRUD: Amenities show Create/Update/Delete with permissions and endpoints
  • Soft-delete + Restore: Properties show Delete and Restore mutations