| name | implement-event-and-update-entity |
| description | Implements a NeoHaskell EVENT: creates the payload module Events/Name.hs (in-file type always named Event), adds the variant to the context Event.hs ADT, extends getEventEntityId with a new branch, and adds the matching case to the Entity.hs update fold. Use right after verify-event-model produces a verified event node, or whenever a command's decide references an event constructor whose payload module does not exist yet — 'add a new event', 'wire the event', 'implement the event side'. Covers the locked payload V2 rule, creation-fact naming (CounterCreated is correct, not a CRUD smell), and command- first vs event-first ordering. Do NOT use to implement a command's decide (implement- command), to only ADD A FIELD to an existing entity record backward-compatibly (expand- entity), for queries (implement-query), integrations (implement-integration), or wiring (wire-feature). Runs in the GREEN phase. |
| metadata | {"model":"sonnet"} |
This is NeoHaskell, not vanilla Haskell. The .hs extension is shared, but the payload type is always named Event (not after the file), the update fold must be exhaustive, and all imports go through import Core and qualified modules — never import Prelude or import Data.Aeson.
Inputs / Outputs / Next
- Input: a verified event node from
verify-event-model (event name, entity, fields).
- Output:
Events/<Name>.hs payload module + updated Event.hs ADT + extended Entity.hs update fold.
- Next:
expand-entity (if a new field is needed on the entity) · implement-command · write-unit-tests · write-feature-tests
TDD role — GREEN
This skill runs in the GREEN phase: the red unit test (write-unit-tests) has already described the event's effect on the entity. Implement exactly enough to make that test pass. Do not add fields or logic the test has not asked for.
Command↔event ordering: create the event payload + ADT variant before or alongside the emitting command (implement-command), not after. The decide function references event constructors that must compile first.
Two valid layouts — split vs single-file
Two entity/event layouts are valid and interchangeable; pick one per context and mirror it consistently.
- Split scaffold — what
neo new generates, and what Steps 1–3 below describe: Entity.hs, Event.hs, a Core.hs barrel, and one Events/<Name>.hs payload module per event (in-file type always named Event).
- Single-file — the authoritative testbed layout (e.g.
Testbed.Cart.Core): one Core.hs holding the entity and the event ADT with inline record constructors — no separate Events/ modules. Fewer files, one template to mirror.
The single-file/inline layout is what triggers the ambiguous-fields caveat in Step 3: its inline event constructors share field names with the entity via DuplicateRecordFields.
Import block for a single-file Core.hs:
import Core -- Entity, NameOf/EventOf/EntityOf, Default, Text, Uuid (type), Generic
import Json qualified -- FromJSON/ToJSON instances
import Service.Command.Core (Event (..)) -- the Event class (getEventEntityIdImpl)
import Uuid qualified -- Uuid.nil (the FUNCTIONS; the type comes from Core)
getEventEntityId and update are not exported — they are used only by the Event/Entity instances. Mirror Testbed.Cart.Core's export list: (CartEntity (..), CartEvent (..), initialState).
Step 1 — Create Events/<Name>.hs (payload) [LOCKABLE once deployed]
The in-file type is always Event, regardless of the file name. neo inspect identifies payload files by looking for data Event inside any Events/<Name>.hs file; a differently named type will be invisible to tooling.
Grounded in the Counter starter layout (verified against neo/src/inspect/parse.rs test fixtures and reflected in neohaskell-module-layout).
-- src/Starter/Counter/Events/CounterIncremented.hs
-- [LOCKABLE once deployed — create CounterIncrementedV2.hs for any change]
module Starter.Counter.Events.CounterIncremented (
Event (..),
) where
import Core
data Event = Event
{ entityId :: Uuid
, amount :: Int
}
deriving (Generic, Show)
Replace Counter/CounterIncremented/fields with your context and event. Field types must be NeoHaskell types: Uuid, Text, Int, Natural Int, Array Foo, Maybe Foo — never String, [a], or Data.UUID.UUID.
For a creation event the payload always carries the entity id (so update can populate the entity's id field):
-- src/Starter/Counter/Events/CounterCreated.hs
-- [LOCKABLE once deployed]
module Starter.Counter.Events.CounterCreated (
Event (..),
) where
import Core
data Event = Event
{ entityId :: Uuid
, label :: Text
}
deriving (Generic, Show)
Creation facts (CounterCreated, CartCreated, MemberRegistered, LoanOpened) are correct event names — they are not CRUD smells. The smell is a present-tense or RPC-echo name (CreateCounter, ProcessIncrement).
Step 2 — Update Event.hs (context ADT)
Event.hs holds only the sum type and getEventEntityId. Each constructor wraps the corresponding payload module. JSON instances live on the ADT (not on the individual payload types).
Add the new variant to the existing ADT and extend getEventEntityId with a new branch. Both must be exhaustive.
Grounded in the Counter starter (reflected in neohaskell-module-layout).
-- src/Starter/Counter/Event.hs
module Starter.Counter.Event (
CounterEvent (..),
getEventEntityId,
) where
import Core
import Json qualified
import Starter.Counter.Events.CounterCreated qualified as CounterCreated
import Starter.Counter.Events.CounterIncremented qualified as CounterIncremented
data CounterEvent
= CounterCreated CounterCreated.Event
| CounterIncremented CounterIncremented.Event
deriving (Generic, Show)
instance Json.FromJSON CounterEvent
instance Json.ToJSON CounterEvent
-- Must cover every constructor — a non-exhaustive match is a compile warning
-- and a runtime crash when an unmatched event fires.
getEventEntityId :: CounterEvent -> Uuid
getEventEntityId event = case event of
CounterCreated ev -> ev.entityId
CounterIncremented ev -> ev.entityId
Pattern for adding a third variant CounterDecremented CounterDecremented.Event:
- Add
import Starter.Counter.Events.CounterDecremented qualified as CounterDecremented.
- Add
| CounterDecremented CounterDecremented.Event to the ADT.
- Add
CounterDecremented ev -> ev.entityId to getEventEntityId.
- Add the case to
update in Entity.hs (Step 3).
Do all four together — the compiler will tell you exactly which branches are missing.
Step 3 — Extend update in Entity.hs
Entity.hs owns the update :: CounterEvent -> CounterEntity -> CounterEntity fold and all type instances. The fold must be exhaustive: GHC treats a missing constructor as a warning under -Wall and a runtime crash at replay time.
Grounded in testbed/src/Testbed/Cart/Core.hs (update fold) and the Counter Entity.hs pattern from neohaskell-module-layout.
-- src/Starter/Counter/Entity.hs (excerpt — the update fold)
update :: CounterEvent -> CounterEntity -> CounterEntity
update event entity = case event of
CounterCreated ev ->
entity
{ counterId = ev.entityId
, label = ev.label
, value = 0
}
CounterIncremented ev ->
entity { value = entity.value + ev.amount }
The full Entity.hs with all instances for reference:
-- src/Starter/Counter/Entity.hs
module Starter.Counter.Entity (
CounterEntity (..),
initialState,
) where
import Core
import Json qualified
import Service.Command.Core (Event (..))
import Starter.Counter.Event (CounterEvent (..), getEventEntityId)
import Starter.Counter.Events.CounterCreated qualified as CounterCreated
import Starter.Counter.Events.CounterIncremented qualified as CounterIncremented
import Uuid qualified
data CounterEntity = CounterEntity
{ counterId :: Uuid
, label :: Text
, value :: Int
}
deriving (Generic)
instance Json.FromJSON CounterEntity
instance Json.ToJSON CounterEntity
instance Default CounterEntity where
def = initialState
initialState :: CounterEntity
initialState =
CounterEntity
{ counterId = Uuid.nil
, label = ""
, value = 0
}
type instance NameOf CounterEntity = "CounterEntity"
-- Both directions of the entity-event relationship live in Entity.hs,
-- not in Event.hs, to avoid orphan-instance warnings and import cycles.
type instance EventOf CounterEntity = CounterEvent
type instance EntityOf CounterEvent = CounterEntity
instance Entity CounterEntity where
initialStateImpl = initialState
updateImpl = update
instance Event CounterEvent where
getEventEntityIdImpl = getEventEntityId
update :: CounterEvent -> CounterEntity -> CounterEntity
update event entity = case event of
CounterCreated ev ->
entity
{ counterId = ev.entityId
, label = ev.label
, value = 0
}
CounterIncremented ev ->
entity { value = entity.value + ev.amount }
Ambiguous-fields caveat (most common in the inline/single-file layout). Under DuplicateRecordFields + NoFieldSelectors, -Werror=ambiguous-fields (GHC-02256, "Ambiguous record update with parent type constructor") fires on an update case whose record update entity {f = .., g = ..} sets only fields that also exist on the matched event constructor — there is no entity-only field to anchor the type. (Most cases are safe because they set an entity-only field such as status, or an id the events don't carry.) Two fixes: (a) include an entity-only field in the update, or (b) reconstruct via the constructor — SessionEntity { … } — since -Wambiguous-fields applies to record update only, not construction. Canonical detail in neohaskell-records-and-json.
Locked payload — V2 rule
If Events/<Name>.hs is listed in .locked-files (check with grep "src/.../Events/<Name>.hs" .locked-files), do not edit it. Instead:
- Create
Events/<Name>V2.hs. The in-file type is still Event (not EventV2); only the module path changes.
- Add a new ADT variant
<Name>V2 <Name>V2.Event to Event.hs.
- Add the corresponding
<Name>V2 ev -> ... branch to getEventEntityId and update.
- Wire the new variant into the emitting command (see
neo-immutability-and-versioning).
Leave the original Events/<Name>.hs byte-identical. Do not rename or delete it.
Wrong V2 naming forms (reject these): Events/foo_v2.hs, Events/Foo.V2.hs, Events/Foov2.hs (lowercase v), data EventV2 = ....
DO / DON'T table
| DON'T | DO | Why |
|---|
data CounterIncremented = CounterIncremented { ... } in the payload file | data Event = Event { ... } | neo inspect matches the literal type name Event; any other name is invisible to tooling |
Put all events in one ADT file and skip Events/ | One Events/<Name>.hs per event; ADT in Event.hs | Events/ path is what neo lock watches; fat-ADT payloads cannot be individually locked |
entityId :: String or amount :: [Char] | entityId :: Uuid, amount :: Text or Int | String does not exist in the NeoHaskell prelude |
import Data.Aeson / deriving (FromJSON) | import Json qualified + empty instance Json.FromJSON CounterEvent | The framework resolves JSON via Json module, not Data.Aeson directly |
Partial case event of in update (only some constructors) | Exhaustive case event of covering every constructor | A missing branch silently passes -Wall but crashes at replay time for the missing event |
Leave getEventEntityId without the new branch | Add the branch in the same commit as the payload | The dispatcher panics at runtime when an unmatched event fires |
pure entity at the end of update for no-op events | entity (the record, unchanged) | pure is not a NeoHaskell idiom in a pure function; it compiles but violates style |
Use $ or <> in update | Use ` | >for piping,++for append,{}` record update |
| Edit a deployed (locked) payload file |
Verify
After writing the three changes, run:
neo build
A successful build confirms:
Events/<Name>.hs exports Event (..) and the module name mirrors the file path.
- The ADT variant compiles (qualified import resolves).
getEventEntityId is exhaustive (no GHC pattern-match warnings).
update is exhaustive over all constructors.
type instance EntityOf / type instance EventOf resolve without orphan warnings.
If neo build reports a pattern-match warning on update or getEventEntityId, add the missing branch — do not use _ -> entity wildcards (they mask future exhaustiveness regressions).