Skip to content

Event Model DSL: Structural combinators #578

@NickSeagull

Description

@NickSeagull

Parent Issue

#573

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 a Collect writer monad + phantom-tagged Builder newtype
  • All type references via TypeApplications (@Type) using Typeable + 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 @Cart

Design

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 instances

Builder 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 EventListLevel

Type 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 + therefore

Heterogeneous 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

  1. outboundIntegration / inboundIntegration — symmetric naming, avoids ambiguity with Integration.hs
  2. readsFrom instead of reads — the issue's reads [@A, @B] syntax isn't valid Haskell. readsFrom @A per-entity is cleaner and consistent with the builder pattern
  3. when has no conflictControl.Monad.when is NOT re-exported from Basics or Core
  4. Typeable constraint — ensures types exist at compile time. Name extracted via TypeName.reflect @t (gives qualified names like Testbed.Cart.Core.CartEntity)
  5. WiringBuilder shared — outbound and inbound integrations both need triggersCommand + onEntity, so they share the same builder
  6. EventListBuilder sharedgiven and therefore blocks both collect event type refs
  7. No GND / DerivingStrategies — manual Functor/Applicative/Monad instances on Collect and Builder, following project convention
  8. Assembly via Array.reduce — heterogeneous items are partitioned with Array.reduce + Array.push (no mapMaybe)

Depends On

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-exports
  • core/service/EventModel/Types.hs — AST data types
  • core/service/EventModel/Core.hs — Collect monad + Builder + combinators

Modified Files

  • core/nhcore.cabal — add new modules to exposed-modules

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions