ADR: Cross-Resource Writes from ResourceBehavior Hooks
Status: Accepted (Implemented) Date: 2026-04-07 Context: With the factory-based service injection from the Injecting Services into ResourceBehavior ADR, behaviors can now hold references to application services. This ADR decides which service behaviors use when they need to create, update, or delete other resources, and how that dependency is wired without breaking Fx construction ordering.
Problem
Presets increasingly need reactive logic that spans more than one resource type. A concrete driver: porting the legacy education preset from ic-crm (wepala/weos-private-presets#1) requires behaviors that:
- create N
attendance-records when anenrollmentis created - generate
education-events from acourse-instance’s schedule - delete all
attendance-records when aneducation-eventis deleted - mark an
invoiceaspaidwhen apaymentarrives
The previous ADR added a BehaviorServices struct with Resources (ResourceRepository), Triples (TripleRepository), ResourceTypes (ResourceTypeRepository), and Logger. These are sufficient for reads, but not for creating, updating, or deleting other resources.
Why ResourceRepository.Save() is the wrong answer
BehaviorServices.Resources is a ResourceRepository, which does expose Save, Update, Delete. But those methods take already-constructed *entities.Resource values and write them to the projection/event store directly. Using them from a behavior would bypass:
- Schema validation (
validateAgainstSchema) - JSON-LD graph assembly (
BuildResourceGraph) - Reference extraction into triple events (
ExtractAndStripReferences) Resource.Publishedsignal recordingBeforeCreate/BeforeCreateCommit/AfterCreatehooks on the target type- UnitOfWork transactional commit with atomic triple events
Every one of those is load-bearing. Skipping them would corrupt projections, lose references, and silently break other behaviors.
The correct write path is application.ResourceService.Create / Update / Delete, which runs the full pipeline. The question is how to hand that to behaviors.
Why we can’t put ResourceService directly in BehaviorServices
resourceService holds a ResourceBehaviorRegistry (the behaviors field in application/resource_service.go) so it can dispatch hooks. ResourceBehaviorRegistry is built by ProvideResourceBehaviorRegistry, which (after the factory ADR) invokes each preset factory with a BehaviorServices. If BehaviorServices carried a ResourceService, Fx would need:
ResourceService → ResourceBehaviorRegistry → BehaviorServices → ResourceService
A cycle. Fx detects this at container build and fails.
We need a way to give behaviors a full-pipeline write capability without creating that construction cycle.
Options
Option 1: Expose the full ResourceService via a lazy proxy
Define a narrow ResourceWriter interface containing just Create, Update, Delete — the subset behaviors actually need. Add a lazyResourceWriter struct that satisfies ResourceWriter but starts with a nil inner target. Construct it early, hand it to BehaviorServices, build the registry with factories closing over it. Then, after ProvideResourceService has returned the real service, run an fx.Invoke (WireResourceWriter) that sets the proxy’s target. By the time any hook fires during a request, the proxy forwards to the real service.
Example:
type ResourceWriter interface {
Create(ctx context.Context, cmd CreateResourceCommand) (*entities.Resource, error)
Update(ctx context.Context, cmd UpdateResourceCommand) (*entities.Resource, error)
Delete(ctx context.Context, cmd DeleteResourceCommand) error
}
type lazyResourceWriter struct {
svc ResourceWriter // set post-construction
}
func (l *lazyResourceWriter) Create(ctx context.Context, cmd CreateResourceCommand) (*entities.Resource, error) {
if l.svc == nil {
return nil, fmt.Errorf("ResourceWriter.Create called before wiring")
}
return l.svc.Create(ctx, cmd)
}
// Update, Delete similarly.
func (l *lazyResourceWriter) SetTarget(svc ResourceWriter) { l.svc = svc }
Fx wiring in application/module.go:
fx.Provide(newLazyResourceWriter),
fx.Provide(ProvideResourceBehaviorRegistry), // receives *lazyResourceWriter
// ...
fx.Provide(ProvideResourceService),
fx.Invoke(WireResourceWriter), // svc -> proxy
Behaviors close over the proxy via BehaviorServices.Writer:
func newEnrollmentBehavior(s application.BehaviorServices) entities.ResourceBehavior {
return &enrollmentBehavior{writer: s.Writer, resources: s.Resources, logger: s.Logger}
}
Pros:
- Breaks the cycle cleanly. Construction order is
proxy → registry → service → invoke wires proxy. No Fx-visible cycle. - Preserves full semantics. Writes go through schema validation, graph assembly, triple extraction, event recording, UoW commit, and nested behavior dispatch. The target type’s own behaviors fire naturally — a course-instance behavior creating education-events correctly triggers the education-event behavior that seeds attendance.
- Narrow interface.
ResourceWriterhas three methods. Behaviors never seeList*/GetByIDvia the writer; they useBehaviorServices.Resourcesfor reads, which is already the correct separation. - Fails loudly on misuse. If a factory or something else accidentally invokes a hook during startup (before
WireResourceWriterruns), the proxy returns a clear error instead of a nil-pointer panic. - Zero changes to existing behaviors.
personBehaviorandorganizationBehaviorignoreWriter— they continue to work unchanged.StaticBehaviorstill hides service plumbing for pure-transform hooks. - Idiomatic Go proxy pattern. The two-phase setup is an explicit, readable seam, not hidden magic.
Cons:
- Two-phase construction is explicit. There’s a brief window — between
newLazyResourceWriterreturning andWireResourceWriterrunning — when calls through the proxy fail. This is an intentional trade-off: it’s how we break the cycle. The failure mode is clear and the window is startup-only. - Mutable state in a singleton. The proxy’s target is set exactly once, but it is mutated after construction. This can look surprising compared to fully immutable DI. Mitigation:
SetTargetis doc’d as one-shot,WireResourceWriteris the only caller in production code. - Slight indirection at runtime. Every write through a behavior pays one extra interface hop. Negligible compared to the work the real service does.
- Requires a recursion guard. Now that behaviors can create resources, we have to bound cascades. This is actually a benefit (see §”Recursion guard” below), but it’s a new piece of surface area.
Option 2: Split resourceService into an author + a behavior dispatcher
Factor resourceService into two pieces: a resourceAuthor that does the full write pipeline (validation, graph assembly, event recording, UoW commit) without calling any behaviors, and a thin resourceService that wraps it and layers behavior dispatch on top. BehaviorServices.Writer gets the resourceAuthor. Because the author has no behavior dependency, there is no cycle.
Pros:
- No lazy state. The author is fully constructed, nothing is set after the fact.
- Architecturally cleaner. Separates “write a resource” from “run user-supplied hooks around writes”.
- No fx.Invoke dance. Standard constructor injection.
Cons:
- Kills nested behaviors. If a course-instance behavior creates an education-event via the author, the education-event’s own behavior never fires. That means the education-event behavior would not create attendance records when the course-instance behavior generates events. We would need to re-implement the entire cascade inside the course-instance behavior, manually invoking every downstream behavior. This is exactly the logic we want to avoid writing by hand — it’s the whole reason behaviors exist.
- Silent semantic divergence. Writes initiated by a user through the HTTP API run behaviors; writes initiated by a behavior do not. Two code paths with the same signature and different semantics is a bug magnet.
- Double implementation. The author needs its own test coverage of validation, graph assembly, triple extraction, etc. — a duplication of what
resourceServicealready does. - More refactor surface. Tests that construct
resourceServicehave to decide which piece they want.
Option 3: Provide the full ResourceService via lazy proxy (no interface narrowing)
Same as Option 1 but the proxy satisfies the full ResourceService interface (queries included), not a narrow ResourceWriter. Behaviors get one thing that does everything.
Pros:
- One service instead of two on
BehaviorServices(theResourcesrepo becomes redundant for most purposes). - Slightly less code in
resource_behaviors.go.
Cons:
- Blurs the read/write seam. Reads should go through
Resources(the repository, which respects visibility scopes viaFindAllByTypeWithFilters’s scope parameter). Writes should go through the service (which enforces permissions viacheckInstanceAccess). Merging them tempts behaviors to do both through the service and forget the scope arguments. - Wider surface for mistakes. A behavior calling
ResourceService.Listinside a hook is almost always wrong — it’s doing per-row work in a per-request context. Keeping the query methods out ofBehaviorServices.Writerdiscourages this. - Ties
BehaviorServicesto the larger interface. Every timeResourceServicegrows a method, the behavior surface grows with it.
Option 4: Require the education preset (and anything like it) to subscribe to domain events instead of using behaviors
Accept that behaviors remain read-only + same-entity mutation. Cross-resource work goes through Resource.Published event subscribers (per the Event Handler Data Availability ADR). The preset would export a Subscribe function that presets/register_custom.go (or the core module) wires to the event dispatcher.
Pros:
- No changes to
BehaviorServicesor the behavior interface. - Reuses the existing
Resource.Publishedsignal and event subscription infrastructure. - Preset handlers get full DI via Fx.
Cons:
- Splits preset logic across two mechanisms. Type definitions + trivial transforms go in
Behaviors; anything touching another resource goes inSubscribe. A preset author has to learn both models and keep them in sync. - Presets lose self-containment at the Fx level. Subscribing to events requires an
fx.Invokeor similar wiring outside thePresetDefinitionstruct. The private-preset package would need to export more than justRegister(registry)— a real breaking change to the custom-preset contract. - Weaker story for account-level toggling.
BehaviorMeta.Manageablealready gives per-account behavior on/off. Event subscribers don’t benefit from that — we’d need to reinvent it. - Harder to test. Event subscribers need the dispatcher and pericarp plumbing to exercise; behaviors can be called directly from a unit test.
Comparison Matrix
| Criteria | Option 1: Lazy ResourceWriter |
Option 2: Author split | Option 3: Lazy full ResourceService |
Option 4: Event subscribers |
|---|---|---|---|---|
| Breaks the construction cycle | Yes (post-construct wire) | Yes (no dependency) | Yes (post-construct wire) | N/A (no dependency) |
| Preserves nested behavior cascade | Yes | No | Yes | N/A (behaviors unused) |
| Write path runs full pipeline | Yes | Yes (in author) | Yes | Yes (via service) |
| Read/write API clearly separated | Yes | Yes | No | N/A |
Changes to BehaviorServices |
Adds Writer field |
Adds Writer field |
Adds Writer field |
None |
| Changes to preset registration contract | None | None | None | Yes — preset must export subscribers |
| Changes to existing behaviors | None | None | None | None |
Ease of writing an education-scale preset |
Direct: all logic in behaviors | Painful: cascade must be hand-rolled | Direct | Split across behaviors + handlers |
| Failure mode on misuse | Clear error (proxy not wired) | N/A | Clear error | N/A |
| New machinery to maintain | Proxy + 1 fx.Invoke + recursion guard |
Two services with overlapping code | Proxy + 1 fx.Invoke + recursion guard |
Event wiring in private-preset package |
Decision
Option 1 (lazy ResourceWriter). It preserves nested behavior semantics — the whole reason we have behaviors — while cleanly breaking the construction cycle. The narrow interface keeps the write surface small and discourages behaviors from doing per-request queries through the service. Existing behaviors don’t change; new behaviors opt into the Writer field only when they need it.
Option 2 was tempting for its architectural purity, but losing the nested cascade would force preset authors to reimplement the dispatch loop by hand — exactly the boilerplate that behaviors exist to eliminate.
Option 3 is a strict superset of Option 1 and adds nothing we need; the narrow interface is a better default.
Option 4 remains valid for cross-aggregate reactions spanning multiple UoW commits (the case the Event Handler Data Availability ADR was written for). It is not the right tool for the in-request, same-transaction cascades the education preset needs.
Recursion guard
Now that behaviors can create resources, runaway cascades are a real risk. A bug in behavior A that (indirectly) triggers behavior A again would hang or blow the stack. application/resource_service.go tracks a behavior-cascade depth on context.Context via the unexported enterResourceCall helper and a maxBehaviorRecursionDepth constant; Create, Update, and Delete each call it at their top.
Two properties matter:
- Sibling-safe accounting. Because
context.Contextis immutable, two writes issued from the same hook both derive children at depth N+1 — not N+1 and N+2. Legitimate fan-out (an enrollment behavior creating many attendance records) does not inflate the counter. - Conservative ceiling. The limit of 8 was chosen to comfortably fit the deepest legitimate education-preset cascade (course-instance → education-event → attendance-record → invoice → payment → invoice-update, ~6 levels) with headroom, while still failing fast on cycles.
If a real-world preset needs deeper nesting, raising the constant is a one-line change and a new test. It is not a config knob because it should be a code-reviewed decision, not per-deployment tuning.
Implementation
application/resource_behaviors.go: defineResourceWriter,lazyResourceWriter,newLazyResourceWriter,WireResourceWriterapplication/resource_behaviors.go: addWriter ResourceWriterfield toBehaviorServicesapplication/resource_behaviors.go:ProvideResourceBehaviorRegistrytakes*lazyResourceWriterand passes it inBehaviorServicesapplication/module.go:fx.Provide(newLazyResourceWriter)+fx.Invoke(WireResourceWriter)afterProvideResourceServiceapplication/resource_service.go:enterResourceCall+maxBehaviorRecursionDepth+ calls at the top ofCreate,Update,Deleteapplication/resource_behaviors_test.go: test fixtures supply a fake or real*lazyResourceWriterapplication/resource_service_test.go: direct tests forenterResourceCall(boundary, sibling immutability) and per-method guard enforcementdocs/_howto/create-behavior.md: §3a mentionsWriterand shows a cross-resource example- End-to-end cascade test once a consumer (e.g. the education preset) lands