-
-
Notifications
You must be signed in to change notification settings - Fork 10
Event Model DSL: Structural combinators #578
Description
Parent Issue
Summary
Implement the structural combinators for the Event Model DSL: eventModel, chapter, slice, entity, command, produces, outboundIntegration, triggersCommand, onEntity, query, readsFrom, inboundIntegration. These build a pure data structure representing the event model — no side effects, no code generation at this stage.
Also includes the business rule combinators: rules, rule, given, givenNothing, when, therefore, thereforeRejected, thereforeQueryContains, event.
Motivation
The structural DSL is the core of the event model file. It captures the architecture (entities, commands, events, integrations, queries) in a type-safe, declarative format that can be consumed by code generators, validators, and visualization tools.
Scope
- Define data types for the event model AST (
EventModel,Chapter,Slice,EntityDecl,CommandDecl, etc.) - Implement HSpec-style
do-block combinators using aCollectwriter monad + phantom-taggedBuildernewtype - All type references via TypeApplications (
@Type) usingTypeable+TypeName.reflect - Pure data structure output — no IO, no TH at this stage
- Business rule types and combinators (Given/When/Therefore)
Usage
The module is imported unqualified so the DSL reads like natural language:
import EventModel
myModel :: EventModel
myModel = eventModel "Invoicing App" do
chapter "Evaluate Proposal" do
slice "Upload PDF" do
entity @Proposal do
command @UploadProposalPdf do
produces @PdfUploaded
produces @UploadTimestampRecorded
outboundIntegration @TranscribePdf do
triggersCommand @RecordTranscription
onEntity @Proposal
rules do
rule "PDF is uploaded for existing proposal" do
given do
event @ProposalCreated
when @UploadProposalPdf
therefore do
event @PdfUploaded
event @UploadTimestampRecorded
rule "Cannot upload to nonexistent proposal" do
givenNothing
when @UploadProposalPdf
thereforeRejected
slice "View Evaluated" do
query @EvaluatedProposal do
readsFrom @Proposal
readsFrom @ProposalMetricEvaluation
rules do
rule "Shows proposal after full evaluation" do
given do
event @ProposalCreated
event @PdfUploaded
event @EvaluationTriggered
event @MetricEvaluated
thereforeQueryContains @EvaluatedProposal
slice "Periodic Cart Creation" do
inboundIntegration @PeriodicCartCreator do
triggersCommand @CreateCart
onEntity @CartDesign
Module Structure
core/service/
├── EventModel.hs # Re-exports Types + Core
├── EventModel/Types.hs # AST data types + TypeRef
└── EventModel/Core.hs # Collect monad + Builder + combinators
AST Types (EventModel/Types.hs)
| Type | Purpose |
|---|---|
TypeRef |
Compile-time validated reference to a Haskell type (stores Text name via TypeName.reflect) |
EventModel |
Top-level: name + chapters |
Chapter |
Name + slices |
Slice |
Name + entities, queries, inbounds, rules |
EntityDecl |
Entity type ref + commands + outbound integrations |
CommandDecl |
Command type ref + produced event type refs |
IntegrationDecl |
Outbound integration type ref + target command + target entity |
QueryDecl |
Query type ref + reads-from entity type refs |
InboundDecl |
Inbound integration type ref + target command + target entity |
RuleDecl |
Rule name + given clause + when command + then outcome |
RuleGiven |
GivenEvents [TypeRef] or GivenNothing |
RuleThen |
ThenEvents [TypeRef] or ThenRejected or ThenQueryContains TypeRef or ThenUnspecified |
Builder Mechanism (EventModel/Core.hs)
Collect monad — simple writer monad using Array internally:
newtype Collect item value = Collect (value, Array item)
-- Manual Functor/Applicative/Monad instancesBuilder newtype — phantom-tagged wrapper around Collect:
newtype Builder level item value = Builder (Collect item value)
-- Manual Functor/Applicative/Monad instances (delegate to Collect)Phantom level tags prevent mixing combinators across DSL levels:
data ModelLevel
data ChapterLevel
data SliceLevel
data EntityLevel
data CommandLevel
data WiringLevel
data QueryLevel
data RulesLevel
data RuleLevel
data EventListLevelType aliases for each DSL level:
type ModelBuilder = Builder ModelLevel Chapter
type ChapterBuilder = Builder ChapterLevel Slice
type SliceBuilder = Builder SliceLevel SliceItem
type EntityBuilder = Builder EntityLevel EntityItem
type CommandBuilder = Builder CommandLevel TypeRef
type WiringBuilder = Builder WiringLevel WiringItem -- shared by outbound + inbound
type QueryBuilder = Builder QueryLevel TypeRef
type RulesBuilder = Builder RulesLevel RuleDecl
type RuleBuilder = Builder RuleLevel RuleItem
type EventListBuilder = Builder EventListLevel TypeRef -- shared by given + thereforeHeterogeneous levels use internal sum types (SliceItem, EntityItem, WiringItem, RuleItem) that get partitioned into the correct record fields by assembly helpers.
Combinator Signatures
| Level | Combinator | Signature |
|---|---|---|
| Top | eventModel |
Text -> ModelBuilder () -> EventModel |
| Model | chapter |
Text -> ChapterBuilder () -> ModelBuilder () |
| Chapter | slice |
Text -> SliceBuilder () -> ChapterBuilder () |
| Slice | entity |
forall entityType. (Typeable entityType) => EntityBuilder () -> SliceBuilder () |
| Slice | query |
forall queryType. (Typeable queryType) => QueryBuilder () -> SliceBuilder () |
| Slice | inboundIntegration |
forall inboundType. (Typeable inboundType) => WiringBuilder () -> SliceBuilder () |
| Slice | rules |
RulesBuilder () -> SliceBuilder () |
| Entity | command |
forall cmdType. (Typeable cmdType) => CommandBuilder () -> EntityBuilder () |
| Entity | outboundIntegration |
forall intType. (Typeable intType) => WiringBuilder () -> EntityBuilder () |
| Command | produces |
forall eventType. (Typeable eventType) => CommandBuilder () |
| Wiring | triggersCommand |
forall cmdType. (Typeable cmdType) => WiringBuilder () |
| Wiring | onEntity |
forall entityType. (Typeable entityType) => WiringBuilder () |
| Query | readsFrom |
forall entityType. (Typeable entityType) => QueryBuilder () |
| Rules | rule |
Text -> RuleBuilder () -> RulesBuilder () |
| Rule | given |
EventListBuilder () -> RuleBuilder () |
| Rule | givenNothing |
RuleBuilder () |
| Rule | when |
forall cmdType. (Typeable cmdType) => RuleBuilder () |
| Rule | therefore |
EventListBuilder () -> RuleBuilder () |
| Rule | thereforeRejected |
RuleBuilder () |
| Rule | thereforeQueryContains |
forall queryType. (Typeable queryType) => RuleBuilder () |
| EventList | event |
forall eventType. (Typeable eventType) => EventListBuilder () |
Key Design Decisions
outboundIntegration/inboundIntegration— symmetric naming, avoids ambiguity withIntegration.hsreadsFrominstead ofreads— the issue'sreads [@A, @B]syntax isn't valid Haskell.readsFrom @Aper-entity is cleaner and consistent with the builder patternwhenhas no conflict —Control.Monad.whenis NOT re-exported fromBasicsorCoreTypeableconstraint — ensures types exist at compile time. Name extracted viaTypeName.reflect @t(gives qualified names likeTestbed.Cart.Core.CartEntity)WiringBuildershared — outbound and inbound integrations both needtriggersCommand+onEntity, so they share the same builderEventListBuildershared —givenandthereforeblocks both collect event type refs- No GND / DerivingStrategies — manual Functor/Applicative/Monad instances on
CollectandBuilder, following project convention - Assembly via
Array.reduce— heterogeneous items are partitioned withArray.reduce+Array.push(nomapMaybe)
Depends On
EventVariantOftypeclass (Event Model DSL:EventVariantOftypeclass + Decider modifications #575) — already mergedOutboundIntegrationtypeclass (Event Model DSL:OutboundIntegrationtypeclass + dispatch generation #576) — for later validation, not needed for structural ASTInboundIntegrationtypeclass (Event Model DSL:InboundIntegrationtypeclass #579) — same
Note: The structural DSL only captures type references. It does NOT depend on the actual typeclass instances — those are validated separately (#581).
Affected Files
New Files
core/service/EventModel.hs— re-exportscore/service/EventModel/Types.hs— AST data typescore/service/EventModel/Core.hs— Collect monad + Builder + combinators
Modified Files
core/nhcore.cabal— add new modules toexposed-modules